Parking Lot LLD — Session Recap¶
Starting point¶
Fresh session, no prior chat on this topic (unlike Vending Machine, which had a previous conversation). Goal: design Parking Lot as Dassault interview prep, same style as Vending Machine LLD — think out loud first, code second.
You came in with a first draft already sketched (VType, Vehicle, Slot, ParkingLot, Token classes with rough method signatures) and asked to walk through it critically rather than start from zero.
What we did¶
1. Grounded the physical flow first¶
Before requirements-as-checklist, walked through what actually happens at a parking lot gate: - Car arrives → system checks if a slot of the right size is free - If yes: assign slot, generate ticket (ticket ID, slot ID, entry time), open barrier - If no: reject entry - On exit: scan ticket → compute duration → compute fee → payment → free slot → open barrier
Decision made: system assigns the spot at entry (not driver self-select), since that's the version requiring real allocation logic.
2. Reviewed your first-draft code, flagged issues¶
Your original draft had:
Slot { SupportsVType, isAvailable, OccupiedAt, Reserve(Vehicle), Free() }
ParkingLot { Park(), CheckAvailability(VType), Checkin(slot, vehicle), Checkout(Token) }
Token { Id, VId, Vtype, SlotId, CheckinTime, CheckoutTime }
Issues called out:
1. SupportsVType was exact-match only — a Bike couldn't use a Car/Truck slot even if empty. Needed a size hierarchy instead.
2. GetAvailable (read) then Reserve (write) is a check-then-act race — two threads could both find the same free slot before either reserves it.
3. Ambiguous ownership of CheckinTime — should be stamped inside Token.Create, not passed in by caller.
4. No guard against double-checkout on the same token.
5. Slot.Reserve(Vehicle) coupled Slot to Vehicle identity unnecessarily — Slot only needs its own state; the vehicle↔slot link belongs in Token.
3. Resolved the size hierarchy question¶
You asked: is this a Strategy pattern? Answer: no, size compatibility is just a comparison, not Strategy.
enum SlotSize { Small = 1, Medium = 2, Large = 3 }
enum VType { Bike, Car, Truck }
Bike → Small, Car → Medium, Truck → Large. Compatibility = slot.Size >= vehicle.RequiredSize, not exact match. Chose smallest-fitting-slot-first allocation policy (avoid wasting a Large slot on a Bike): .OrderBy(s => s.Size).
Distinguished this from where Strategy does apply: if the selection policy among compatible slots needs to vary (nearest-first, smallest-fit-first, VIP-floor-first), that's Strategy (ISlotAllocationStrategy). Size matching itself never needed it.
4. Named all race conditions before fixing any¶
- Check-then-act on slot selection (read availability, then write reserve — two steps, not atomic)
- Double-checkout — same token redeemed twice concurrently
- (Mentioned, out of scope) slot list mutation vs iteration if slots can be added/removed live
5. Concurrency control — taught from scratch, ATM analogy¶
Built up the mental model in this order:
- The problem: read-check-write is 3 separate steps; two threads interleaving between them causes lost updates (ATM double-withdrawal example: two ATMs both read ₹1000, both approve ₹800 withdrawal, bank loses ₹800).
- lock: "one person in the bathroom at a time, everyone else waits outside holding for the key." someObject in lock(someObject) is just an arbitrary reference type used purely as an identity for who currently holds the critical section — it has no functional role itself. Locking on slot itself (not a shared dummy object) gives per-slot granularity, so Car A locking Slot 1 doesn't block Car B from claiming Slot 2.
- Interlocked: for single-variable atomic flips only (one CPU instruction, no waiting in line). Interlocked.CompareExchange(ref location, newValue, comparand) = "if location currently equals comparand, atomically set it to newValue; return what it was before." Doesn't work on bool — needs an int (1/0) backing field.
- Rule of thumb: lock for multi-step critical sections; Interlocked for a single atomic variable operation.
- Why not DB-level concurrency: lock/Interlocked only work within one process's memory. Multiple gates/servers sharing a DB need DB-level atomicity instead — UPDATE slots SET is_available=false WHERE id=@id AND is_available=true, check rows affected == 1, or SELECT ... FOR UPDATE. In-memory version's lock/Interlocked is the transaction; DB version replaces it with an atomic conditional update.
6. Built the canonical in-memory version¶
Slot.TryReserve() using Interlocked.CompareExchange on a backing int — atomic check+reserve in one call, no external lock needed. ParkingLot.Checkin loops candidate slots (smallest-fitting first), calling TryReserve() on each; if one fails (someone else grabbed it a moment ago), moves to the next candidate rather than failing the whole check-in. Only errors out if every candidate is exhausted.
Token.Redeem(checkoutTime) returns false if already redeemed — guards double-checkout at the token level (noted this itself would need Interlocked/lock too under real concurrent calls on the same token).
7. Pricing — two strategies¶
Flat/Hourly (HourlyPricingStrategy):
hours = Max(Ceiling(duration.TotalHours), 1)
fee = hours * ratePerHour[vehicleType]
Mechanically: TotalHours gives fractional hours (e.g. 3.33), Ceiling rounds any partial hour up to a full hour (any-partial-hour-counts-as-full-hour billing rule, same as real garages), Max(_, 1) enforces a minimum 1-hour charge floor.
Slab/Tiered (SlabPricingStrategy) — first version, using previousBoundary + hoursBilled tracking:
foreach (tier in tiers) {
if (hoursBilled >= totalHours) break;
hoursInTier = max(min(tier.UpToHour, totalHours) - previousBoundary, 0);
total += hoursInTier * tier.RatePerHour;
hoursBilled += hoursInTier;
previousBoundary = tier.UpToHour;
}
This is genuinely a Strategy use case (unlike slot-size) since the algorithm itself varies, not just a comparison threshold.
Traced with 5-hour park, tiers [1hr@₹20, up-to-3hr@₹15, 4hr+@₹10]:
- Tier 1: min(1,5)-0=1 → ₹20
- Tier 2: min(3,5)-1=2 → ₹30
- Tier 3: min(Max,5)-3=2 → ₹20
- Total = ₹70
Then traced again with a 2-hour park to show the min clamp kicking in early (tier 2 only gets 1 new hour instead of 2, since the car left before reaching hour 3).
8. Simplified the slab algorithm (final version)¶
You pushed back that the boundary-tracking version was more convoluted than necessary. Rebuilt using a countdown/bucket-draining model instead — no previousBoundary variable at all:
class PricingTier
{
public int FromHour { get; }
public int UpToHour { get; }
public decimal RatePerHour { get; }
}
public decimal Calculate(VType type, TimeSpan duration)
{
var tiers = _tiersByType[type];
int totalHoursParked = Math.Max((int)Math.Ceiling(duration.TotalHours), 1);
int hoursRemaining = totalHoursParked;
decimal totalFee = 0;
foreach (var tier in tiers)
{
if (hoursRemaining <= 0) break;
int hoursThisTierCanHold = tier.UpToHour - tier.FromHour;
int hoursUsedInThisTier = Math.Min(hoursRemaining, hoursThisTierCanHold);
totalFee += hoursUsedInThisTier * tier.RatePerHour;
hoursRemaining -= hoursUsedInThisTier;
}
return totalFee;
}
Tier definitions now carry their own width (FromHour/UpToHour) instead of relying on a running boundary tracked outside the tier. Mental model: "I have N hours left to bill. This tier can hold up to W hours. Use whichever is smaller, subtract from what's left, move to next tier." One counter (hoursRemaining), counting down to zero — matches the loop's exit condition directly.
Re-traced 5-hour example, same ₹70 result, cleaner mechanics:
hoursRemaining=5
Tier1: width=1 → min(5,1)=1 → bill ₹20 → remaining=4
Tier2: width=2 → min(4,2)=2 → bill ₹30 → remaining=2
Tier3: width=huge → min(2,huge)=2 → bill ₹20 → remaining=0
Total=₹70
Concepts covered¶
| Concept | What it is | Why it matters here | Analogy that landed |
|---|---|---|---|
| Check-then-act race | Reading state then writing it as two separate steps | Two threads can both read "available" before either writes "taken" | Two ATMs both reading ₹1000 balance before either subtracts |
lock |
Mutual exclusion — only one thread inside a block at a time, others wait | Correctness by serializing access to a multi-step critical section | Bathroom door + key — one person in, everyone else queues outside |
Interlocked.CompareExchange |
Atomic single-instruction check-and-set on one variable, no waiting | Fixes the race on a single flag/counter without lock overhead | N/A — explained as "CPU guarantees this one instruction can't be interrupted mid-way" |
| Lock granularity | Locking per-slot vs one lock for the whole lot | Per-slot lock lets unrelated check-ins proceed concurrently; one shared lock serializes everything unnecessarily | N/A |
| DB-level concurrency | Atomic UPDATE ... WHERE + rows-affected check, or SELECT FOR UPDATE |
lock/Interlocked only work within one process's memory — multi-server/gate systems need the DB itself as referee |
N/A |
| Strategy pattern — correct use | Interchangeable algorithms, not just threshold comparisons | Slab pricing genuinely varies in algorithm; slot-size compatibility is just a >= comparison and doesn't need Strategy |
Contrasted directly against the size-hierarchy question to avoid over-applying Strategy everywhere |
| Slab/tiered pricing — marginal billing | Each rate band only bills the hours strictly inside it, not the whole duration at one rate | Same shape as income tax brackets — avoids the common bug of treating it as a step function | Tax brackets; later, "bucket draining" — hours remaining pour into each tier's bucket up to its width, overflow moves to next bucket |
min() in slab pricing |
Picks whichever is smaller: this tier's own ceiling, or the actual duration | A short stay might not even reach a later tier's boundary — min clamps to reality |
"Whichever number stops us first" |
What we messed up / had to correct¶
- First pass at Strategy pattern instinct was wrong — you initially wondered if size hierarchy needed Strategy. Correct answer: no, it's a simple ordinal comparison. Where Strategy does legitimately apply (slot allocation policy, pricing algorithm) had to be explicitly separated out to avoid pattern-happy over-engineering.
- First slab pricing version was more convoluted than necessary — the
previousBoundary+hoursBilledtwo-variable tracking scheme worked correctly but required holding two synchronized counters in your head simultaneously. You explicitly called this out as unclear, prompting a full simplification to the single-counter countdown model. Correction: tier now owns its own width (FromHour/UpToHourpair) instead of the caller reconstructing width from a running boundary. Interlockedonbooldoesn't work — flagged early: it only supportsint,long,objectreferences, etc. Backing field had to be anint(1 = available, 0 = taken) instead of a realbool.- Didn't wrap early canonical version in a DB transaction — you correctly caught that the original in-memory design had no DB and thus no transaction — this was intentional (in-memory
lock/Interlockedis the transaction equivalent at the memory level), but worth remembering the DB-backed version is structurally different (atomic conditionalUPDATE), not just "add a lock."
Key values / config to remember¶
| Item | Value |
|---|---|
| Slot size hierarchy | Small=1 (Bike), Medium=2 (Car), Large=3 (Truck), compatibility = slot.Size >= vehicle.RequiredSize |
| Slot allocation policy chosen | Smallest-fitting-slot-first (OrderBy(s => s.Size)) |
| Example flat rates | Bike ₹10/hr, Car ₹20/hr, Truck ₹50/hr |
| Example slab tiers (Car) | Hour 1 → ₹20, Hours 2–3 → ₹15/hr, Hour 4+ → ₹10/hr |
| 5-hour slab trace result | ₹70 total |
| 2-hour slab trace result | ₹35 total |
| Concurrency primitive used | Interlocked.CompareExchange(ref _isAvailableInt, 0, 1) — 1=available, 0=taken |
| Multi-server DB equivalent | UPDATE slots SET is_available=false WHERE id=@id AND is_available=true, check rows affected == 1 |
Unanswered questions / things to investigate¶
- Whether pricing rate/tiers should vary per VType in the final design (mentioned as a decision point —
Dictionary<VType, List<PricingTier>>vs one global tier list) — not fully locked in, worth deciding explicitly before interview. Token.Redeemidempotency under real concurrency (two threads callingCheckouton the same token simultaneously) was flagged as needing its ownInterlocked/lock guard, but not yet implemented — currently just a null-check, not atomic.- Multi-floor extension not discussed at all this session (was raised as an open requirements axis early on, then the physical-flow-first pivot meant it never got revisited).
- Payment method / integration (cash, card, wallet) mentioned as a category but not designed — only the fee calculation, not the payment processing flow.
What's next¶
- Decide per-VType tier variation (does Bike have its own slab tiers, or does slab pricing only apply to Car/Truck?) — quick decision, do this first.
- Harden
Token.RedeemwithInterlocked.CompareExchangeon a_redeemedint flag, matching theSlot.TryReservepattern — this was explicitly flagged as unfinished. - If time allows before the interview, do one more pass converting the in-memory
TryReserveinto the DB-backed atomicUPDATEversion, just to have both versions rehearsed and be able to talk through the transition live if asked "how would this scale." - Consider whether
ISlotAllocationStrategy(nearest-first vs smallest-fit-first) is worth building out as an actual pluggable interface, or just mentioning verbally as a design option — lower priority, only do this if the interview seems to be probing allocation policy specifically.