AlgoInsight

Free lesson · Low Level Design Interviews

Step 5: Edge Cases and Tests

Hard13 min readlldinterview-prepoop

Step 5 - Edge Cases & Tests

by Olena Marchenko - Software Engineer II, Microsoft Azure Developer Tools.

The implementation in Step 4 produced the central operations. enter atomically allocates a spot, occupies it, decrements the counter, creates and stores a ticket. exit atomically validates, charges, transitions, releases, increments. The Strategy interfaces are called. The lock is held. The lifecycle is enforced through transitionTo. That's the baseline.

What I've learned from my own production work on Azure Dev Tools - and from the parking-lot-flavored problems I bombed in earlier interviews - is that the interesting failures are at the edges. The lot full case. The vehicle-spot mismatch case. The lost ticket case. The concurrent gate race. Each one of these is a real scenario in a production parking system, and each one reveals different gaps in a design.

This step walks through verification under time pressure - 4-5 minutes from minute 37 to minute 41. The interviewer's perspective on what to evaluate, the candidate's perspective on how to surface the right scenarios, the specific edge cases parking lot demands, and the testing strategy that demonstrates robustness without overrunning the budget.

The Interviewer's Perspective

When the candidate transitions to verification, my evaluation focuses on robustness thinking.

Did they walk through scenarios with specific values? Concrete walkthroughs catch bugs that abstract reasoning misses. Strong candidates pick three or four scenarios with named vehicles, spots, and outcomes.

Did they surface lot-full and mismatch cases? These are parking-lot-specific. Lot full → return Optional.empty from enter. Vehicle-spot mismatch (e.g., truck arriving when no large spot is available) → also Optional.empty. Strong candidates distinguish these: "no compatible spot" can mean either "no spot of any kind" or "spots exist but none compatible with this vehicle." The system response is the same; the operational distinction matters for monitoring.

Did they handle the lost ticket case? Parking-lot-specific edge case. A vehicle arrives at exit, can't present a ticket. The system has to handle this - usually a flat penalty fee. The candidate's design should accommodate this without major refactoring.

Did they walk through concurrent gate operations? Two entry gates calling enter simultaneously. Same vehicle would be impossible; different vehicles competing for the last spot is the interesting case. The lock serializes; the second gate's enter either gets the spot or returns Optional.empty.

Did they address ticket lifecycle violations? Trying to exit twice with the same ticket. Trying to pay an already-closed ticket. Trying to enter without a ticket. The lifecycle validation in transitionTo catches these.

Did they catch any bugs? The walkthrough often reveals issues. Strong candidates catch and acknowledge.

Did they articulate testing strategy? Unit tests for behavior. Concurrency tests with multiple threads. State machine tests for the ticket lifecycle. Property-based tests for invariants (counter consistency).

Did they protect time for Step 6? Hard cutoff at minute 41. Closing has its own minutes.

Verification generates about 5-8% of the signal in absolute terms - small but with outsized leverage on borderline votes.

The Candidate's Perspective

Verification for parking lot has specific shape:

Transition deliberately at minute 37. Hard cutoff on implementation.

Pick parking-lot-specific scenarios. Happy path. Lot full. Vehicle-spot mismatch. Lost ticket. Concurrent gate race. Each one probes a different part of the design.

Surface edge cases organized by category. Input edges, boundary cases, lifecycle violations, concurrency edges, failure modes.

Investigate the subtle case. Find one case the implementation doesn't immediately handle. Trace through. Either confirm correctness or surface the gap.

Articulate testing in three or four layers. Unit, concurrency, state machine, property-based.

Time-discipline at 4-5 minutes.

Applied to Parking Lot

The verification work has specific shape for this problem.

Choosing Scenarios

For parking lot specifically, the highest-leverage scenarios:

flowchart TD
    S[Scenario Category] --> S1[Happy Path<br/>basic enter/exit]
    S --> S2[Capacity Edges<br/>lot full / mismatch]
    S --> S3[Lifecycle<br/>state violations]
    S --> S4[Concurrency<br/>gate race]
    S --> S5[Failure Modes<br/>payment / lost ticket]
    
    S1 --> R1[Confirms<br/>basic mechanics]
    S2 --> R2[Confirms<br/>availability logic]
    S3 --> R3[Confirms<br/>state machine]
    S4 --> R4[Confirms<br/>lock correctness]
    S5 --> R5[Confirms<br/>failure handling]
    
    style S2 fill:#fbbf24,stroke:#f59e0b,color:#000
    style S4 fill:#a78bfa,stroke:#8b5cf6,color:#fff
    style S5 fill:#f87171,stroke:#ef4444,color:#fff

Scenario 1: Happy Path

Lot with 2 levels, each with 4 spots: 1 Compact, 2 Regular, 1 Large.

Car arrives. enter(car):

Later, exit(ticketId, CARD):

Roughly 60 seconds. Confirms the basic flow.

Scenario 2: Lot Full

Same lot but all 8 spots occupied. Bike arrives. enter(bike):

Roughly 30 seconds. Confirms lot-full handling.

Scenario 3: Vehicle-Spot Mismatch

Lot has only Compact and Large spots; all Regular and Handicap removed. 4 Compact, 4 Large. Truck arrives. enter(truck):

This works - Large accepts trucks. Now a more interesting case: lot has only Compact spots, all available. Truck arrives.

Operationally distinct from "lot full" - spots exist but none compatible. But the return value is the same. A real system might want to distinguish for monitoring (alert "lot has Compact only; reject trucks"), but for the LLD scope Optional.empty is acceptable.

The articulation: "Vehicle-spot mismatch returns Optional.empty just like lot-full. Operationally distinguishable for monitoring, but the API response is the same - the candidate can't enter. A more sophisticated design might return an enum result (FULL, NO_COMPATIBLE_SPOT) but our sealed ExitResult isn't this granular on the enter side. Worth noting as an extension."

Roughly 60 seconds.

Scenario 4: Concurrent Gate Race

Two entry gates, last spot available. Two cars arrive at the same instant.

Thread 1 (Gate 1): enters with Car_A. Thread 2 (Gate 2): enters with Car_B.

Both threads call enter simultaneously. Both hit lock.lock(). Suppose Thread 1 acquires first.

Thread 1:

Thread 2 (now acquires):

Thread 1 returned ticket. Thread 2 returned empty. No race condition; the lock serialized the two operations. Car_B's gate would display "lot full" and reject entry.

The articulation: "The lock serializes the two enters. Whichever thread acquires first gets the spot; the other sees the post-allocation state and finds no spot. No double-allocation. The atomicity is preserved by the lock pattern."

Roughly 60 seconds.

Scenario 5: Lost Ticket

Vehicle parked. Driver lost the ticket. At exit, they don't have a ticket ID.

Our current API doesn't directly handle this - exit takes a ticketId. For a lost ticket flow, the design needs:

The articulation: "Lost ticket is a scenario the current design doesn't directly handle. The API needs a second exit path - exitWithoutTicket(vehicle, paymentMethod). The lookup is by Vehicle rather than ticket ID. The fee calculation skips PricingStrategy and uses a flat penalty. Lifecycle goes ACTIVE → LOST → CLOSED. The supporting design - Ticket.transitionTo accepts LOST as a valid transition from ACTIVE - already accommodates this. The change is the API surface, not the entity model. Extension I'd add in Step 6."

This is the catch-and-acknowledge pattern. The implementation has a gap; the design accommodates it cleanly; the change is small.

Roughly 60 seconds.

Edge Cases by Category

Input cases:

Null vehicle. "enter rejects null with NPE via Objects.requireNonNull. Defensive against caller errors."

Null ticket ID at exit. "exit rejects null ticket ID similarly."

Null payment method. "Same. Method-level null-check."

Boundary cases:

Empty lot. "Constructor rejects empty levels list with IAE. Lot must have at least one level."

Single-level lot. "Strategy iterates the one level; same behavior as multi-level, just shorter iteration."

All spots OUT_OF_SERVICE. "findAvailable returns Optional.empty for any vehicle (canFit returns false when status != AVAILABLE). enter returns Optional.empty."

Lifecycle cases:

Double exit. "Second exit looks up ticket; activeTickets no longer has it (removed on first exit). Returns Failure 'Ticket not found.'"

Pay an already-closed ticket. "transitionTo from CLOSED throws IllegalStateException - terminal state. The lifecycle protects against this."

Enter without paying for the previous stay. "Different ticket IDs; new enter creates a new ticket. The previous stay is unrelated unless we add per-vehicle stay tracking."

Concurrency cases:

Two enters racing for the last spot. "Walked above. Lock serializes."

Enter and exit racing. "Same lock; serialized. The release frees a spot that the subsequent enter can take."

Counter consistency. "Counters update inside the lock with status changes. Property-based test would verify counters always match the actual spot counts."

Failure modes:

Payment failure. "paymentProcessor.process returns Failure. exit returns ExitResult.Failure. Ticket stays ACTIVE - vehicle still parked. Driver can retry."

PaymentProcessor exception. "If process throws unexpectedly, the lock is released via try-finally, but the ticket state and spot state are unchanged (no transition happened yet). The vehicle is still parked; the driver can retry."

Crash mid-operation. "Atomicity inside the lock means partial updates aren't visible. But if the JVM crashes mid-operation, activeTickets is lost. Persistence layer would address this - out of scope but worth noting."

A Real Bug Catch

Walking through the lock pattern, I might notice:

"Let me double-check the order of operations in enter. allocate → occupy → decrement counter → create ticket → store ticket. If anything in this sequence throws, the operations before it have happened - the spot might be occupied without the ticket being stored, or with the counter not decremented.

Currently: allocate is read-only on the strategy side. occupy on the spot updates status to OCCUPIED. decrement updates the counter. Creating the ticket is just object construction. Storing in activeTickets puts an entry.

If occupy throws - well, occupy can only throw if the spot wasn't AVAILABLE. But the strategy checked canFit which checks status... so this shouldn't happen unless there's a race outside the lock, which we've excluded by holding the lock the whole time.

If decrement throws - Map.merge can throw on overflow but our counters won't overflow.

So the sequence is actually safe under the lock invariant. No rollback needed because no individual step can fail given the prior steps succeeded.

But if I were paranoid, I'd extract this into a method that has explicit rollback for compensation. For our scope, the lock invariant gives us atomicity sufficient for correctness."

That's the kind of paranoid trace that reveals subtle thinking. The conclusion is "no bug" but the investigation is the signal.

Testing Strategy in Layers

About 60 seconds.

"Testing strategy in four layers.

Unit tests for behavior. enter happy path. enter rejected (lot full). exit happy path. exit rejected (payment fail). Ticket transitions valid/invalid. canFit for each spot-type/vehicle-type combination. Counter increment/decrement. Maybe 25-30 focused tests.

Concurrency tests. Multiple threads simulating gates. Verify: no spot double-allocation, no counter drift, no ticket ID collision. Use CountDownLatch to start threads simultaneously and run for thousands of iterations.

State machine tests. For ParkingTicket. Every from-state, every to-state, valid and invalid. Confirm valid transitions succeed and invalid throw.

Property-based tests for invariants. Most important. Invariants: (1) sum of available counters across spot types equals count of AVAILABLE spots, (2) every active ticket references an OCCUPIED spot, (3) every OCCUPIED spot is referenced by exactly one active ticket. Run randomized enter/exit sequences and check after each operation.

I wouldn't ship without all four layers. The property-based tests catch subtle bugs around counter consistency that targeted unit tests miss."

The Conversation

What the verification phase sounds like.

Strong Candidate

Interviewer: [checks time] "About 5 minutes left."

Candidate: "Let me walk through scenarios. Five categories: happy path, lot full, vehicle-spot mismatch, concurrent gates, lost ticket.

Happy path: lot with two levels, mixed spots. Car enters. Strategy finds Regular #1. Spot occupied, counter REGULAR on Level 1 decrements 2→1. Ticket created and stored. Later: exit, ticket found ACTIVE, fee calculated (2.5 hr stay → 3 hr ceiling × $5 =

5, below daily cap), payment succeeds, ticket ACTIVE → PAID → CLOSED, spot released, counter 1→2.

Lot full: 8 spots all occupied, bike arrives. Strategy iterates, no available spots anywhere, returns empty. enter returns Optional.empty. No state changes.

Vehicle-spot mismatch: lot has only Compact spots, all available, truck arrives. Compact.canFit(truck) returns false for every spot. Strategy returns Optional.empty. enter returns Optional.empty. Operationally distinct from lot-full but same API response. Worth noting as an extension if monitoring needs the distinction."

Candidate: "Concurrent gates: two cars arrive at the same instant, one spot left. Both threads enter lock.lock. One acquires first - say Thread 1. Thread 1: allocation gets the spot, occupies, decrements counter, creates ticket, stores. Unlocks. Thread 2 acquires: allocation iterates, finds no available spot now, returns empty. enter returns empty. Lock serializes correctly. No double allocation.

Lost ticket: scenario the current API doesn't directly handle. Need a second exit path - exitWithoutTicket(vehicle, paymentMethod) - that looks up by vehicle, uses a flat penalty fee, transitions ACTIVE → LOST → CLOSED. The Ticket lifecycle already accommodates LOST as a valid transition from ACTIVE; the change is API surface, not entity model. Extension I'd add in Step 6."

Candidate: "Edge cases by category.

Input: null vehicle/ticket-id/payment-method all rejected via requireNonNull.

Boundary: empty levels list rejected in constructor; single-level lot is same as multi just shorter iteration; all spots OUT_OF_SERVICE means findAvailable returns empty for any vehicle.

Lifecycle: double-exit returns 'Ticket not found' Failure (already removed). Paying a closed ticket throws via transitionTo (CLOSED is terminal). Multiple stays for same vehicle are independent tickets.

Concurrency: enter-enter race serialized; enter-exit race serialized.

Failure: payment failure returns ExitResult.Failure; ticket stays ACTIVE; driver retries."

Candidate: "One subtle case: enter has a sequence - allocate, occupy, decrement, create ticket, store. If any step throws, partial state could result. I traced it: each step's failure conditions are either impossible given the lock invariant (occupy can't fail if canFit passed) or non-throwing (object construction, map put). So no rollback is needed. For paranoid scope, an explicit rollback compensation could be added."

Candidate: "Testing in four layers: unit tests for behaviors, concurrency tests with multiple threads, state machine tests for ticket lifecycle, property-based tests for invariants like counter consistency."

About 4 minutes. Five scenarios with specific traces. Edge cases in five categories. One subtle-case investigation. Testing strategy in four layers. Strong cumulative signal.

Weak Candidate

Interviewer: [checks time] "About 5 minutes left."

Candidate: "Edge cases. Null vehicle would be a problem."

Interviewer: "Walk through a scenario."

Candidate: "If the lot is full, enter returns null."

Interviewer: "Optional.empty, right?"

Candidate: "Right, Optional.empty."

Interviewer: "What about concurrent gates?"

Candidate: "The lock serializes them."

Interviewer: "Walk through what happens."

Candidate: "Two threads try to enter, one gets the lock first, takes the spot, the second one sees no spots."

Interviewer: "What about ticket lifecycle?"

Candidate: "Tickets transition Active to Paid to Closed."

Interviewer: "What if someone tries to pay a closed ticket?"

Candidate: "It would throw because CLOSED is terminal."

Interviewer: "What about lost tickets?"

Candidate: "Hmm. I don't think the current design handles lost tickets directly."

Interviewer: "What would you change?"

Candidate: "Add another exit method that takes a vehicle instead of a ticket ID."

Interviewer: "How would you test this?"

Candidate: "Write tests for the operations."

Interviewer: "What kinds?"

Candidate: "Unit tests."

About 4 minutes. The candidate produced acceptable answers but each scenario was shallow and prompted. No subtle-case investigation. Testing strategy at the single-layer level. The interviewer's read: "knows the parts when prompted but doesn't surface them deliberately."

Strong vs Weak: Verification

Dimension Strong Candidate Weak Candidate
Initiation Self-initiated at minute 37 Drifts; starts late
Scenario count 5 with traced state 1-2 shallow
Vehicle-spot mismatch Distinguished from lot-full Not surfaced
Lost ticket Gap acknowledged with extension proposal "Doesn't handle it"
Concurrency walkthrough Full trace with state at each step Surface description
Edge category coverage 5 categories Random list
Subtle case investigated Sequence atomicity reasoning None
Testing strategy 4 layers articulated "Unit tests"
Time discipline Lands at minute 41 Trails or runs over

Common Mistakes

The parking lot verification failures:

Skipping verification entirely. Implementation runs to minute 41, no walkthrough. Major negative signal.

Generic edge cases. "Null inputs, empty inputs, large inputs." No parking-lot-specific scenarios.

Missing the lost ticket case. Parking-lot-specific. Strong candidates surface it; weak ones don't.

Glossing concurrency. "The lock serializes" without actually tracing through two threads.

Defensive about gaps. Walkthrough surfaces a gap; the candidate argues it doesn't matter. Bad signal.

No counter-consistency invariant. Missing the property-based test for the cache invariants.

No state machine tests for ticket. Missing the explicit lifecycle testing.

Warning: The most parking-lot-specific verification miss is skipping the lost-ticket case. It's the parking-lot equivalent of "what if payment fails for movie booking" - a real production scenario that the candidate's design should anticipate.

What "Good" Looks Like

Self-initiated at minute 37.

Five scenarios with specific traces. Happy path, lot full, mismatch, concurrent, lost ticket.

Edge cases in five categories. Input, boundary, lifecycle, concurrency, failure.

One subtle case investigated. Demonstrates real reasoning.

Testing in four layers. Unit, concurrency, state machine, property-based.

Gap acknowledged honestly. Lost ticket as an extension.

Confidence in own code. Walks through with certainty.

Clean closing. "That covers the main cases."

Try It

Exercise: Drill the verification phase for parking lot:

  1. Practice the five-scenario walkthrough. Happy path, lot full, vehicle-spot mismatch, concurrent gates, lost ticket. Each in 45-60 seconds with traced state.

  2. Drill the five-category edge case sweep. Ninety seconds total.

  3. Practice the lost-ticket articulation. Acknowledge the gap; propose the extension; show the lifecycle already accommodates it.

  4. Drill the four-layer testing articulation. Unit, concurrency, state machine, property-based. Each with what it catches.

  5. Practice the subtle-case investigation. Pick the enter sequence; trace through; conclude no bug or surface one.

The next step (Step 6) covers extensibility and trade-offs - closing the interview with extension projection and trade-off articulation. The verification here proves the design is robust; Step 6 proves the design is positioned for change.

Back to Low Level Design Interviews