Skip to content

9.7.4 — Parking Lot

"Design a parking lot: cars come in, get a ticket, park, and pay on the way out."

A shopping centre car park has one space left on level 3. Two cars pull up at two different entry barriers at the same moment. Both barriers ask the system "where should this car go?", both get told level 3, bay 44, and both barriers open. Ninety seconds later two drivers are staring at each other across one parking space, and one of them has to reverse back down three ramps to an exit they have already been ticketed through.

That is the whole problem in one picture. A parking lot is not hard because of the class diagram. It is hard because choosing a space and getting a space are two different things, and almost every follow-up question an interviewer asks is a way of finding out whether you noticed.

The rule this design has to hold: at most one vehicle is ever assigned to a bay, and every vehicle that got through a barrier has a bay it can actually use. Everything below exists to make that rule true when ten barriers are opening at once.

1. The structure, and why it nests

A lot has floors, a floor has bays. That sounds like a detail about buildings, and it is actually the thing that makes availability cheap to answer.

typescript
class Bay {                                                     // (1)
  #occupant: VehicleId | null = null;

  constructor(
    readonly id: BayId,
    readonly size: BaySize,                                     // (2)
    readonly floor: FloorId,
    readonly walkingDistance: Record<GateId, number>,           // (3)
  ) {}

  get isFree(): boolean { return this.#occupant === null; }

  fits(v: VehicleType): boolean { return FITS[v].includes(this.size); }   // (4)
}

class Floor {                                                   // (5)
  constructor(readonly id: FloorId, readonly bays: Bay[]) {}

  freeCount(v: VehicleType): number {                           // (6)
    return this.bays.filter(b => b.isFree && b.fits(v)).length;
  }
}

class ParkingLot {
  constructor(readonly floors: Floor[]) {}

  freeCount(v: VehicleType): number {                           // (7)
    return this.floors.reduce((n, f) => n + f.freeCount(v), 0);
  }
}

(1) Bay is the smallest thing in the design and the most important one. It is the only object in the entire system allowed to know whether a car is in it. Nothing else holds that fact, so there is nothing else that can disagree with it.

(2) Size is fixed at construction and never changes, because you cannot repaint the lines on a bay while a car is in it. Anything that never changes should be readonly, and here it also means the search can cache size information without worrying about staleness.

(3) Walking distance from each gate, stored per bay. This is a surveyed fact about the building rather than something to compute, and storing it turns "find the nearest free bay to gate B" into a sort rather than a geometry problem.

(4) fits asks a table, not a chain of if statements. Section 3 is about that table.

(5) and (6) A floor is a group of bays that can answer the same questions a bay can, one level up. freeCount on a floor is the sum over its bays.

(7) And freeCount on the lot is the sum over its floors. This is the Composite pattern from 9.4.11, and the payoff is that the availability display, the entry barrier and the "lot full" sign all ask the same question of whichever level they care about. Adding a "zone" between floor and bay later, because level 3 gets split into short-stay and long-stay halves, means one more layer that answers the same questions. Nothing that asks has to change.

The honest note about freeCount as written: it walks every bay every time it is called. A 2,000-bay lot with a display board refreshing every second is doing two million comparisons a second to render a number. That is fine for the interview and wrong for the building, and the fix is section 6, where the count becomes a maintained number rather than a computed one. Say the plan out loud when you write the naive version, because an interviewer who spots it before you do reads it as an oversight rather than a stage.

2. Choosing a bay and winning a bay are two different operations

Here is the race, drawn.

one free bay left: 3-44Gate A① asks for a bayGate B② asks for a bayallocatoranswers "3-44" to bothA claims 3-44 ✓bay was free, now A'sB claims 3-44 ✗bay already taken,③ B asks againthe loser goes back and picks again — it does not fail, and it does not wait behind a lock
Figure 1 — Choosing is advice, claiming is the decision. Both gates are told the same bay, because at the moment they asked it really was free. Only one claim succeeds. The loser re-runs the choice, which is why the design needs no lock over the whole lot.
typescript
class Bay {
  claim(v: VehicleId): boolean {                    // (1)
    if (this.#occupant !== null) return false;      // (2)
    this.#occupant = v;
    return true;
  }

  release(): void { this.#occupant = null; }
}

class ParkingService {
  constructor(
    private lot: ParkingLot,
    private allocator: BayAllocator,                // (3)
    private tickets: TicketStore,
    private events: LotEvents,
  ) {}

  admit(vehicle: Vehicle, gate: GateId): Ticket {
    for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {   // (4)
      const bay = this.allocator.choose(this.lot, vehicle.type, gate);  // (5)
      if (!bay) throw new LotFullError(vehicle.type);            // (6)

      if (bay.claim(vehicle.id)) {                               // (7)
        const ticket = Ticket.issue(vehicle, bay, gate, now());
        this.tickets.save(ticket);
        this.events.emit("parked", { bay });                     // (8)
        return ticket;
      }
    }
    throw new LotBusyError();                                    // (9)
  }
}

(1) claim is the whole design in four lines. It answers "is this bay free, and if so make it mine" as one operation that nobody can interleave with. The two halves cannot be pulled apart, which is exactly what went wrong for the two drivers in the opening.

(2) The check and the write live inside the same function. If a caller did if (bay.isFree) bay.take(car) instead, another gate could slip between the if and the take. That gap between checking a fact and acting on it is the check-then-act race from 9.5.1, and it is the single most common way LLD answers go wrong under a follow-up question.

(3) The allocator is injected rather than hard-coded, because which free bay you pick is a business decision that will change. Section 4.

(4) A bounded loop rather than while (true). An unbounded retry loop in a system that is genuinely full spins forever burning a CPU core, and the bound turns that into an honest error.

(5) Choosing consults the current state of the lot. The answer is true at the instant it is given and may be false a millisecond later, and the design accepts that instead of trying to prevent it.

(6) If the allocator finds nothing at all, the lot is full for this vehicle type and there is nothing to retry. This is a different outcome from (9) and the difference matters at the barrier: "full" means turn the car away, "busy" means the barrier should ask again.

(7) The claim is where the truth is settled. It returns a boolean rather than throwing, because losing this race is completely normal and expected. Something that happens on a busy Saturday afternoon a hundred times an hour is not an exception.

(8) The event goes out after the claim succeeds and the ticket is saved. Display boards listen to it. Section 6 covers why the boards must never be updated from inside the claim.

(9) Losing MAX_ATTEMPTS races in a row means the lot is so contended that this gate keeps getting beaten. Three attempts is plenty in practice, and the error tells the barrier to retry rather than telling the driver to go home.

Why not just put a lock around the whole lot? It would work, and it is worth being able to explain exactly what it costs before you refuse it. A lot-wide lock means the ten barriers are processed strictly one at a time, so admitting a car takes as long as the slowest step in the whole admission (which includes writing a ticket to a database). On a Saturday morning with a queue at every barrier, ten gates would move at the speed of one. The claim loop lets ten gates work at once and only serialises the individual bays that two gates happen to want at the same instant, which is almost never.

And this is not a trick that only works in memory. When the bays live in a database, claim is one statement:

sql
UPDATE bays SET occupant = $vehicle
 WHERE id = $bay AND occupant IS NULL;         -- rows affected: 1 = won, 0 = lost

The AND occupant IS NULL is the same conditional check, enforced by the database rather than by the object, and the number of rows the statement changed is the answer. Nothing about the design changes when it moves to storage, which is the reason to write claim as a conditional operation rather than a setter in the first place.

3. What fits where, written as data

A motorbike fits anywhere. A car needs a compact bay or bigger. A van needs a large bay. Written as branching code that grows a case per vehicle, this becomes the kind of function nobody wants to touch:

typescript
const FITS: Record<VehicleType, BaySize[]> = {     // (1)
  motorcycle: ["small", "compact", "large"],       // (2)
  car:        ["compact", "large"],
  van:        ["large"],
};

(1) Record<VehicleType, BaySize[]> means the compiler requires an entry for every vehicle type. Add minibus to the VehicleType union and this object stops compiling until you say where a minibus fits, which is a build failure in the right place rather than a minibus silently fitting nowhere at four in the morning. This is the exhaustiveness idea from 3.7.3, used to make a table complete rather than to make a switch complete.

(2) Order matters here, and it is the only subtle thing in the table. Listing small first means a "smallest bay that fits" allocator finds it first without any extra sorting.

The policy question this table sets up, which interviewers ask almost every time: if a motorbike can use a large bay, should it? On a quiet Tuesday, yes, and nobody notices. On a Saturday, a lot that hands out large bays to motorbikes runs out of large bays and starts turning vans away while thirty large bays hold motorbikes. The fix is not in the table. It is the allocator, and that is the next section.

4. The two decisions that are actually product decisions

Two things vary between one car park and the next, and neither of them is a fact about parking. They are choices an operator makes.

typescript
interface BayAllocator {                                        // (1)
  choose(lot: ParkingLot, v: VehicleType, gate: GateId): Bay | null;
}

class NearestToGate implements BayAllocator {                   // (2)
  choose(lot: ParkingLot, v: VehicleType, gate: GateId): Bay | null {
    return lot.freeBays(v)
      .sort((a, b) => a.walkingDistance[gate] - b.walkingDistance[gate])[0] ?? null;
  }
}

class SmallestThatFits implements BayAllocator {                // (3)
  choose(lot: ParkingLot, v: VehicleType): Bay | null {
    return lot.freeBays(v)
      .sort((a, b) => SIZE_ORDER[a.size] - SIZE_ORDER[b.size])[0] ?? null;
  }
}

interface PricingRule {                                         // (4)
  priceFor(v: VehicleType, from: Date, to: Date): Money;
}

(1) One method, one question: given everything you know, which bay. Returning null rather than throwing keeps "there is nothing" as an ordinary answer, because the caller at (6) above already knows what to do with it.

(2) Nearest to the gate is what customers want and what shopping centres use. It has a cost worth naming: it packs the lot from the entrances outwards, so on a busy day the far corners fill last and the queue at the barrier is waiting for people to drive further than they used to.

(3) Smallest that fits is what the operator wants, because it keeps large bays available for vehicles that have no alternative. It has the opposite cost: a customer in a small car gets sent to a tight bay on level 5 while there are large empty bays by the door.

(4) Pricing is the second decision, and it is separate from the first for a reason you can state: they change on different days for different reasons. A car park that introduces weekend surge pricing has not changed where it puts cars, and a car park that starts filling from the bottom floor up has not changed what it charges. Two reasons to change means two seams, which is 9.3.6 stated as a practical test rather than a principle.

Neither of these is the right answer on its own, and saying so is the point. Real operators combine them: nearest-to-gate within the smallest size that fits, so a car gets the closest compact bay rather than the closest bay of any size. That composition is one more allocator that holds the other two, and it is worth writing out because it shows the seam actually absorbs the real requirement instead of just existing:

typescript
class SmallestThenNearest implements BayAllocator {
  choose(lot: ParkingLot, v: VehicleType, gate: GateId): Bay | null {
    for (const size of FITS[v]) {                     // smallest fitting size first
      const bays = lot.freeBays(v).filter(b => b.size === size);
      if (bays.length > 0)
        return bays.sort((a, b) => a.walkingDistance[gate] - b.walkingDistance[gate])[0];
    }
    return null;
  }
}

It walks the sizes a vehicle can use, from smallest, and only sorts by distance inside the size band it settled on. Note that it depends on the ordering choice made back at (2) of the table, which is why that ordering was worth a sentence.

Pricing, worked properly, because this is where money bugs live. A price is computed at exit from two timestamps, and every part of it is a rule somebody argued about:

typescript
class SlabPricing implements PricingRule {
  constructor(
    private firstHour: Money,          // (1)
    private perHourAfter: Money,
    private dailyCap: Money,           // (2)
    private graceMinutes: number,      // (3)
  ) {}

  priceFor(v: VehicleType, from: Date, to: Date): Money {
    const minutes = minutesBetween(from, to);
    if (minutes <= this.graceMinutes) return Money.zero();          // (4)

    const hours = Math.ceil(minutes / 60);                          // (5)
    const raw = this.firstHour.plus(this.perHourAfter.times(hours - 1));
    const days = Math.ceil(hours / 24);
    return Money.min(raw, this.dailyCap.times(days));               // (6)
  }
}

(1) A higher first hour and a lower rate afterwards. This is not arbitrary: the operator's cost is mostly the barrier transaction and the bay turnover, so short stays cost more per hour.

(2) A daily cap stops a five-day airport stay from producing a bill nobody will pay.

(3) A grace period, usually ten or fifteen minutes, exists because people drive in, find the lot full or the shop closed, and drive out. Charging them is technically defensible and commercially stupid.

(4) The grace check is first and returns zero, which also means a driver who enters and immediately exits never needs a payment terminal at all. That is one less machine in the flow that can be broken.

(5) Math.ceil on hours is the rule that every customer complaint is about. Sixty-one minutes is billed as two hours. It is what car parks do, it is what the sign says, and writing it explicitly rather than letting a rounding accident decide is the difference between a policy and a bug.

(6) The cap is applied per day rather than once, so a three-day stay is capped three times. Getting this wrong in the other direction produces a car park where a week costs the same as a day.

The bit that catches people: pricing needs the entry timestamp, and the entry timestamp is on the ticket. So the ticket is not a receipt. It is a record with a lifecycle, and the next section is about that.

5. The ticket owns what happens to it

typescript
type TicketState =
  | { kind: "open" }                                              // (1)
  | { kind: "paid"; amount: Money; at: Date; validUntil: Date }   // (2)
  | { kind: "closed"; exitedAt: Date };                           // (3)

class Ticket {
  #state: TicketState = { kind: "open" };

  private constructor(
    readonly id: TicketId,
    readonly vehicle: Vehicle,
    readonly bay: Bay,
    readonly entryGate: GateId,
    readonly enteredAt: Date,
  ) {}

  settle(payment: Payment, rule: PricingRule, at: Date): void {    // (4)
    if (this.#state.kind !== "open") throw new AlreadySettledError(this.id);
    const due = rule.priceFor(this.vehicle.type, this.enteredAt, at);
    payment.assertCovers(due);
    this.#state = {
      kind: "paid", amount: due, at,
      validUntil: addMinutes(at, EXIT_WINDOW_MINUTES),             // (5)
    };
  }

  exit(at: Date): void {                                          // (6)
    if (this.#state.kind !== "paid") throw new NotPaidError(this.id);
    if (at > this.#state.validUntil) throw new ExitWindowExpiredError(this.id);
    this.bay.release();                                           // (7)
    this.#state = { kind: "closed", exitedAt: at };
  }
}

(1) Open means a car is in a bay and nobody has paid. This is the state a lost ticket is in, and it is the state most tickets spend almost all their life in.

(2) Paid carries the amount, the time and a deadline. That deadline is not decoration, and (5) explains it.

(3) Closed carries the exit time, which is what the operator's reporting runs on.

(4) settle is the only way to move from open to paid, and its first line refuses to do it twice. A second payment on a settled ticket is a real thing that happens when someone taps a card twice at a slow terminal, and the refusal is what turns a double charge into an error message.

(5) The exit window is fifteen or twenty minutes in most car parks, and it exists because pricing is by time. Without it, a driver could pay at the machine, go back to the shops for two hours, and drive out on a ticket priced for the earlier exit. With it, the payment is a quote that expires, and an expired quote sends the driver back to the machine to pay the difference.

(6) and (7) The bay is released at the exit barrier, not at the payment machine. This ordering is the one to defend, because the tempting alternative is releasing the bay when payment succeeds. If you release at payment, the lot immediately believes the bay is free and sends the next arriving car to a space that still has a car in it for the next four minutes while the driver walks back to it. Releasing at the barrier means the count is briefly pessimistic, which costs nothing, instead of briefly wrong, which costs a confrontation on level 3.

The lost ticket, which nobody asks about until they do. A driver arrives at the exit with no ticket. There is no entry time, so there is no price. Every real operator has the same answer, and it is a policy rather than a computation: charge the maximum daily rate, take the number plate, and let the office refund it if the camera footage shows a shorter stay. The design contribution is not inventing a clever recovery. It is knowing that the system must be able to close a ticket it cannot price, which means closeByPlate(plate, reason) is a real operation with a reason field and an audit record, rather than something a supervisor does by editing the database.

6. The display board, and the number that drifts

The sign at the entrance says 147 SPACES. Where does 147 come from?

The version in section 1 counts every bay on every request. Correct, and too slow for a real lot. The obvious repair is to keep a running number and adjust it: add one when a bay is released, subtract one when a bay is claimed. That is fast and it drifts, and it is worth being precise about why, because "it drifts" is the kind of vague claim an interviewer will push on.

It drifts because the count and the bays are two separate facts that are updated by two separate writes. Any path that changes a bay without going through the counter (a maintenance tool marking a bay out of service, a crashed process between the claim and the decrement, a bug in one of eleven callers) leaves the number wrong forever, because nothing ever recomputes it. Errors here do not cancel out. They accumulate, so the sign that was two spaces optimistic in March is thirty spaces optimistic by December.

The fix has two halves, and both are ordinary.

First, the count is maintained by listening to events rather than by every caller remembering:

typescript
class AvailabilityBoard {
  #free = new Map<VehicleType, number>();

  constructor(lot: ParkingLot, events: LotEvents) {
    this.recount(lot);                                       // (1)
    events.on("parked", ({ bay }) => this.#adjust(bay, -1));  // (2)
    events.on("freed",  ({ bay }) => this.#adjust(bay, +1));
  }

  recount(lot: ParkingLot): void {                           // (3)
    for (const v of ALL_VEHICLE_TYPES) this.#free.set(v, lot.freeCount(v));
  }
}

(1) It starts from a full recount, so a restarted board is correct rather than starting at zero.

(2) One listener, not eleven callers. Anything that claims or releases a bay emits, and there is exactly one place that knows how the number moves. This is the Observer arrangement from 9.4.13, and the reason to use it here is not decoupling for its own sake. It is that the correctness of the number depends on nobody forgetting, and one listener is easier not to forget than eleven call sites.

(3) A periodic recount, every few minutes, repairs any drift that happened anyway. This is the honest half. A maintained number plus a slow authoritative recount is what real systems do, because the maintained number is fast and the recount is what stops small errors becoming a sign that lies.

The board must never sit on the path of admitting a car. If updating the sign happened inside admit, a broken display on level 4 would stop the barrier opening. Emitting an event and letting the board react means the worst case is a stale sign, which annoys people, rather than a closed barrier, which strands them.

One more thing the count cannot do, and saying it first is worth more than the count itself. The number on the sign is always slightly wrong, because cars are entering and leaving continuously and no reader of that sign can act on it instantly. The design does not try to make it exact. It makes it approximately right and never authoritative: the sign is advice for drivers on the road outside, and the barrier's claim is the only thing that decides anything. Anyone who tries to make the display exact ends up putting a lock around the lot to do it, which brings back the problem section 2 solved.

7. Where the changes land

Four requests turn up in every version of this interview.

Reserve a bay from an app. A bay gains a third condition beyond free and occupied: held for someone until a time. The claim check becomes "free, or held by this vehicle", and the hold expires by being ignored rather than by a background job sweeping it. That distinction matters more than it looks: a sweeper that clears an expired hold is racing against the driver who is arriving at the barrier at that exact moment, and the loser of that race is a customer who paid for a reservation and got turned away. Treating a hold as expired at read time means there is nothing to race with. This is the same expiry-by-being-ignored move used for seat holds in 9.7.9.

Electric vehicle charging bays. The interesting part is that a charging bay has two resources: the space and the charger. A car can be parked in it with the charger unplugged, and in some lots the charger is shared between two bays. So the model gains a Charger that a bay may reference, with its own occupancy, and pricing gains an energy component that is metered rather than timed. The tell that you have understood the requirement is asking whether a car that has finished charging should be billed at a penalty rate to make it move, because that is a real policy in real lots and it changes the pricing rule rather than the allocator.

Monthly passes. These change admission, not allocation. A pass-holder still goes through the same choose-and-claim loop and still gets the nearest fitting bay. What changes is that the barrier accepts them without issuing a payable ticket. And there is a contradiction hiding in the requirement that you should name rather than code around: passes are sold against expected use, so an operator sells 120 passes for 100 bays because they know everybody never turns up at once. That directly contradicts the promise the pass makes. Either you cap passes at physical capacity and accept the lost revenue, or you oversell and owe the turned-away pass-holder something, or you hold back a small block of bays for pass-holders after a certain hour. All three are defensible. Silently implementing one of them is not, because the operator is the one who has to answer the phone call.

Number-plate recognition instead of tickets. The ticket becomes a record keyed by plate, issued by a camera rather than a printer, and the lifecycle is unchanged. What is genuinely new is that cameras misread plates, at a rate of one or two in a hundred, so there must be a supervised correction path: a session that can be re-keyed to the right plate, with who did it and why recorded. Designs that assume the camera is right produce a customer being billed for someone else's four-day stay with no way to fix it.

8. What the interviewer will push on

"Two gates, one free bay. What happens?" They are checking whether you separate choosing from winning. The good answer names the two operations, makes claim conditional, and has the loser re-run the choice. The tell is whether you can say what claim becomes in a database (UPDATE ... WHERE occupant IS NULL, rows-affected as the verdict) without being prompted, because that shows the shape survived contact with storage. The common wrong answer is if (bay.isFree) bay.take(car), which reads fine and contains the exact gap that put two cars in one space.

"Why not lock the lot?" They are checking whether you can price a simpler alternative rather than reciting that locks are bad. It works and it serialises every barrier, so ten gates move at the speed of one and the slowest step in admission becomes everybody's step. The claim loop only serialises the individual bay that two gates happen to want at the same instant.

"Your retry loop — when does it stop?" They are looking for whether you distinguish two different failures. No bay available at all is LotFull, and there is nothing to retry. Losing three races in a row is LotBusy, and the barrier should ask again. A while (true) in a genuinely full lot spins a core forever, and answering this before being asked is the difference between having written the loop and having thought about it.

"A motorbike can use a large bay. Should it?" They are checking whether you can tell a data question from a policy question. The fits table says what is possible; the allocator decides what is chosen. Hand out large bays to motorbikes on a Saturday and you turn vans away while thirty large bays hold bikes. The good answer also names the opposite cost, which is that smallest-fitting sends a small car to level 5 while large bays sit empty by the door, and then composes the two.

"Where is the bay released — at the payment machine or the exit barrier?" This is the ordering question, and it separates people who have thought about the building from people who have thought about the code. Release at payment and the lot sends the next car to a bay that still has a car in it for the four minutes the driver spends walking back. Release at the barrier and the count is briefly pessimistic, which costs nothing. The follow-up is why a payment expires at all, and the answer is that pricing is by time, so a payment is a quote with a deadline.

"How does the entrance sign know there are 147 spaces?" They are checking whether you notice that a maintained counter drifts and never self-corrects. The full answer is one listener that adjusts on events, plus a periodic recount that repairs drift, plus the statement that the sign is advice and the claim is the only authority. Candidates who make the sign exact end up putting a lock around the lot, which undoes section 2.

The thing to volunteer that nobody asks for: the lost ticket. There is no entry time, so there is no price, and every design that assumes a ticket exists has no operation that can close this one. Saying that the system needs closeByPlate(plate, reason) with an audit record, and that the policy is to charge the daily maximum and refund from the office, shows that you have thought about the exit lane at 11pm rather than only about the class diagram. It is also the cheapest possible signal that you know a supervisor editing the database directly is not an answer.

Recall

  • The rule: at most one vehicle per bay, and every admitted vehicle has a bay it can use.
  • Bay is the only holder of occupancy. Nothing else stores it, so nothing else can disagree.
  • Choosing is advice, claiming is the decision. claim checks and writes in one uninterruptible step and returns a boolean, because losing is normal.
  • The loser re-runs the choice; the loop is bounded, and LotFull (nothing available) is a different answer from LotBusy (kept losing races).
  • In a database, claim is UPDATE ... WHERE occupant IS NULL with rows-affected as the verdict. The shape survives storage.
  • A lot-wide lock works and costs everything: ten barriers move at the speed of one.
  • Composite structure: lot → floor → bay, all answering the same availability question. A new layer costs nothing to the things that ask.
  • Fits is a table, Record<VehicleType, BaySize[]>, so a new vehicle type is a compile error until somebody says where it fits.
  • Two seams because two reasons to change: allocation policy and pricing policy. Real lots compose them (nearest bay within the smallest fitting size).
  • Pricing rounds up by the hour, caps per day, and has a grace period. Each is a stated policy, not a rounding accident.
  • Ticket lifecycle: open → paid → closed. A second payment is refused. Payment carries an expiry, because the price was computed from a time.
  • The bay is released at the exit barrier, not at the payment machine. Briefly pessimistic beats briefly wrong.
  • Availability count: one listener plus a periodic recount. A maintained counter drifts and never self-corrects. The sign is advice; the claim is authority.
  • Boards react to events and never sit on the admission path, so a broken display cannot close a barrier.
  • Reservations expire by being ignored, not by a sweeper that races the arriving driver.
  • Monthly passes contradict overselling. Name the contradiction and price the three resolutions rather than picking one silently.
  • The lost ticket has no entry time. The system needs an audited close-by-plate operation and a stated policy.

Self-test: Why are choosing and claiming separate, and what does the loser do? What does claim become in SQL? What exactly does a lot-wide lock cost? Why is the fits rule a table rather than code? Why does a payment expire? Where is the bay released, and what breaks if you move it? Why does a maintained availability count drift?

Quiz Bank

FoundationalTwo entry barriers ask for a bay at the same moment and one bay is free. Walk the design that gets this right, and show what it becomes in a database.

The mistake to name first, because the interview is really about it. The obvious code is:

typescript
const bay = allocator.choose(lot, vehicle.type, gate);
if (bay.isFree) bay.take(vehicle.id);          // two gates can both pass this line

Between reading isFree and running take, the other gate can do exactly the same thing. Both see a free bay, both take it, and the second write silently overwrites the first. Nothing throws, nothing logs, and the failure is discovered by two drivers on level 3. This is check-then-act from 9.5.1, and the reason it survives so many code reviews is that it reads as if it is safe.

The repair is to make checking and writing one operation that nobody can split:

typescript
claim(v: VehicleId): boolean {
  if (this.#occupant !== null) return false;
  this.#occupant = v;
  return true;
}

Now there is no gap for another gate to fit into, because the decision and the write are the same step. claim returns a boolean rather than throwing, and that choice carries meaning: losing this race is an ordinary event that happens many times an hour in a busy lot, and an exception is for things that are not ordinary.

Then the caller loops:

typescript
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
  const bay = allocator.choose(lot, vehicle.type, gate);
  if (!bay) throw new LotFullError(vehicle.type);
  if (bay.claim(vehicle.id)) return issueTicket(vehicle, bay, gate);
}
throw new LotBusyError();

The loser goes back and picks a different bay. It does not wait, it does not block anyone, and it does not fail. Choosing is advice; claiming is the decision.

The two failures are genuinely different and the barrier treats them differently. LotFullError means the allocator found nothing, so retrying is pointless and the car should be turned away. LotBusyError means this gate kept losing races, which means the lot is contended rather than full, so the barrier should try again. Collapsing them into one error produces a barrier that turns cars away from a lot with sixty free spaces.

In a database, nothing about the design changes:

sql
UPDATE bays SET occupant = $vehicle
 WHERE id = $bay AND occupant IS NULL;

The AND occupant IS NULL is the conditional check, the database guarantees the statement is not interleaved, and the rows-affected count is the boolean. One row means you won. Zero rows means somebody else did, and the loop runs again.

That the same shape works in both places is the reason to write it this way. A design where claim is a plain setter and safety comes from "the service is careful" has to be redesigned the moment it meets storage. This one is transcribed.

Why not lock the whole lot instead? It is correct, and its cost is precise: every barrier is processed one at a time, so admitting a car takes as long as the slowest step inside admission, including the ticket write. Ten barriers on a Saturday morning move at the speed of one. The claim loop lets all ten work at once and only serialises the exact bay that two of them happen to want at the same instant, which almost never happens.

AppliedDesign the pricing and the ticket lifecycle. Include what happens when someone pays and then goes back to the shops.

A price is computed from two timestamps at exit, so the entry timestamp has to live somewhere durable, which is the ticket. That single fact is why the ticket is an entity with a lifecycle rather than a printed receipt.

typescript
priceFor(v: VehicleType, from: Date, to: Date): Money {
  const minutes = minutesBetween(from, to);
  if (minutes <= this.graceMinutes) return Money.zero();
  const hours = Math.ceil(minutes / 60);
  const raw = this.firstHour.plus(this.perHourAfter.times(hours - 1));
  const days = Math.ceil(hours / 24);
  return Money.min(raw, this.dailyCap.times(days));
}

The grace period comes first and returns zero. People drive in, find the shop closed or the lot full, and drive straight out. Charging them is defensible and commercially foolish, and putting the check first means those drivers never touch a payment machine at all.

Math.ceil on hours is a policy, and writing it deliberately is the point. Sixty-one minutes bills as two hours. Every customer complaint about car parks is about this line. It is not a rounding accident, it is what the sign at the entrance says, and a design that lets rounding fall out of whatever the money type happens to do has a policy nobody decided.

The daily cap multiplies by days. Cap once instead of per day and a week costs the same as a Tuesday.

The ticket's states carry different data because they mean different things:

typescript
type TicketState =
  | { kind: "open" }
  | { kind: "paid"; amount: Money; at: Date; validUntil: Date }
  | { kind: "closed"; exitedAt: Date };

Open is a car in a bay with nothing paid. Paid carries the amount, the moment, and a deadline. Closed carries the exit time for the operator's reporting.

Now the question in the title. A driver pays at the machine at 14:00 for a stay priced to 14:00, then remembers one more shop and comes back at 16:10. If the payment had no expiry, they drive out on a price computed two hours ago and the operator loses two hours of revenue on every ticket where this happens, which is a lot of them.

So validUntil is part of the paid state, set to fifteen or twenty minutes after payment. The exit barrier checks it:

typescript
exit(at: Date): void {
  if (this.#state.kind !== "paid") throw new NotPaidError(this.id);
  if (at > this.#state.validUntil) throw new ExitWindowExpiredError(this.id);
  this.bay.release();
  this.#state = { kind: "closed", exitedAt: at };
}

The payment is a quote, and quotes expire. An expired quote sends the driver back to a machine to pay the difference, which is exactly what every real car park does. Notice that this is not a special case bolted on. It falls out of paid carrying a deadline, which it carries because the price was computed from a time.

Two more decisions in that method, both worth defending.

settle refuses to run twice. Its first line rejects any ticket that is not open. Somebody tapping a card twice at a slow terminal is common, and this turns a double charge into an error message.

The bay is released here, at the barrier, and not in settle. Release at payment and the lot believes the bay is free while the driver is still walking back to it, so the next arriving car is sent to an occupied space. Release at the barrier and the count is briefly pessimistic by one bay for a few minutes, which costs nobody anything.

And the case with no clean answer: the lost ticket. No ticket means no entry time means no price. The design cannot compute its way out, so it needs an operation that closes a ticket it cannot price: closeByPlate(plate, reason), audited, with the policy being the maximum daily rate and a refund from the office if footage shows a shorter stay. The design contribution is having the operation exist with a reason field, rather than leaving a supervisor to edit a row in the database at eleven at night.

InterviewThe entrance sign says 147 SPACES. Where does that number come from, and what is wrong with the obvious implementation?

Three implementations, each wrong in a way worth understanding.

Count on every read. Walk every bay, count the free ones that fit. Always correct, and a 2,000-bay lot with a sign refreshing every second does two million comparisons a second to render a number that changes a few times a minute. It is the right thing to write first and the wrong thing to ship.

Keep a running number that every caller adjusts. Fast, and it drifts. The reason is specific rather than vague: the count and the bays are two separate facts written by two separate operations, so any path that changes a bay without going through the counter leaves the number permanently wrong. A maintenance tool marking a bay out of service, a process that dies between the claim and the decrement, one of eleven call sites that forgot. The errors do not cancel out, they accumulate, so a sign that was two spaces optimistic in March is thirty spaces optimistic by December, and nothing in the system ever notices.

The shipped version has two halves. First, one listener owns the number instead of every caller remembering:

typescript
constructor(lot: ParkingLot, events: LotEvents) {
  this.recount(lot);
  events.on("parked", ({ bay }) => this.#adjust(bay, -1));
  events.on("freed",  ({ bay }) => this.#adjust(bay, +1));
}

The reason to use events here is not decoupling as a virtue. It is that the number's correctness depends on nobody forgetting, and one listener is much easier not to forget than eleven call sites spread across the barrier code, the maintenance tool, and the reservation feature somebody adds next quarter. This is 9.4.13 used for a concrete reason.

Second, a periodic recount repairs whatever drifted anyway. Every few minutes, walk the bays and reset the number. This is the honest half of the answer: the maintained count is fast, and the recount is what stops small errors from turning the sign into a liar. Real inventory systems of every kind do this, and a candidate who offers only the maintained counter has designed something that is correct on day one.

Then the property that makes all of this safe, and it is the part to say unprompted: the sign is advice, never authority. It is read by drivers on the road outside, who cannot act on it instantly anyway, so a number that is a few seconds stale is fine. Every decision that actually matters goes through claim, which is exact. Trying to make the sign exact means locking the lot while you count it, which reintroduces the problem the claim loop was built to avoid.

One last thing the board must not do: sit on the admission path. If admit updated the sign before returning, a display on level 4 with a dead network cable would stop a barrier from opening. Emitting an event and letting the board react means the worst case is a stale sign, which annoys people, rather than a stuck barrier, which strands them.

StaffA city operator runs forty lots and wants app reservations, dynamic pricing per lot, plate-recognition entry, and an occupancy data feed. What survives, what changes, and where do you push back?

What survives is the whole core, and that is the design's receipt. The bay as the only holder of occupancy, the choose-then-claim loop, the two policy seams, the ticket lifecycle, and the release-at-the-barrier ordering are all unchanged. Forty lots is forty copies of the same small machine, and each lot is its own consistency boundary. No decision in lot 12 needs to know anything about lot 31, which means there is no cross-lot coordination anywhere in the system. That is worth saying out loud, because it is the property that makes the whole thing scale, and it came free from putting occupancy on the bay.

Dynamic pricing per lot is data, not code. PricingRule instances are configured per lot and reloaded without a deploy. What has to be designed rather than assumed is that a price change must never alter what an already-parked car will be charged. A driver who entered under the morning rate and exits under the evening rate has a legitimate complaint if the new rate applies to their whole stay. So the ticket records which pricing version was in force when it was issued, and exit prices against that version. Pricing changes then apply to arrivals, which is the only rule that survives a customer reading it.

Plate recognition changes the entry adapter and nothing else. A camera issues the session instead of a printer, and the lifecycle is identical. What is genuinely new is that cameras misread one or two plates in a hundred, so there must be a supervised correction: a session re-keyed to the right plate, with the operator and reason recorded. Without it, a customer is billed for someone else's four-day stay and the only fix is a database edit.

The occupancy feed is the display-board seam, grown up. The data team subscribes to the same parked and freed events the sign listens to, shipped as a durable stream rather than an in-process callback. Two rules keep it from becoming a liability. It never sits on the admission path, so a slow consumer cannot delay a barrier. And it carries the recount as a periodic full snapshot, because a consumer that only ever sees deltas has no way to recover from a missed message. That second point is the one people forget, and it is what turns a feed from a demo into something an analyst can trust.

Reservations are where I would push back, and the push-back is specific. The mechanism is easy: a bay gains a held-until state, the claim check accepts "free, or held by this vehicle", and the hold expires by being ignored at read time rather than by a sweeper that races the arriving driver. That part is an afternoon.

The problem is the effect on the lot. Holds take bays out of circulation for people who are already in the queue at the barrier, so a lot can show as full while a dozen held bays sit empty, and the operator loses real revenue to reservations that never arrive. This is not an engineering question and I would refuse to answer it silently. What I would ship is the mechanism plus three things the operator can see and turn: a cap on concurrent holds per lot, a short hold window, and the conversion rate of holds to arrivals reported per lot. If that rate is low, the feature is costing more than it earns, and the operator should learn that from a number rather than from a quarterly revenue chart.

Monthly passes carry the same shape of contradiction and deserve the same treatment. A pass promises access; operators sell more passes than bays because everybody never arrives at once. Those two facts cannot both hold on a Saturday in December. The three honest resolutions are capping passes at physical capacity, overselling with a stated compensation when someone is turned away, or reserving a block of bays for pass-holders after a certain hour. Naming the contradiction and pricing the three options is the work. Picking one quietly in code is how an operator finds out from an angry phone call.

What I would monitor across forty lots. Claim retry rates per lot, because a lot where gates constantly lose races is either genuinely full or has a bug in its allocator. The gap between the maintained count and the periodic recount, because a growing gap is a code path writing bays without emitting. Sessions closed by plate rather than by ticket, because a rise means the ticket printers or the cameras are failing. And hold conversion, which is the number that decides whether reservations stay.

Flashcards

FlashChoosing versus claiming

The allocator's answer is advice that may be stale by the time it is used. claim checks and writes in one uninterruptible step and returns a boolean. The loser re-runs the choice. In SQL this is UPDATE ... WHERE occupant IS NULL with rows-affected as the verdict.

FlashWhy not lock the lot

It works and it serialises every barrier, so ten gates move at the speed of one, including the ticket write. The claim loop only serialises the exact bay two gates want at the same instant.

FlashFull versus busy

LotFull means the allocator found nothing, so turn the car away. LotBusy means this gate lost every race, so the barrier should try again. Collapsing them turns cars away from a lot with sixty free spaces.

FlashWhere the bay is released

At the exit barrier, not at the payment machine. Release at payment and the next car is sent to a bay the driver is still walking back to. Briefly pessimistic costs nothing; briefly wrong costs a confrontation.

FlashWhy a payment expires

The price was computed from a timestamp, so the payment is a quote. Without validUntil, a driver pays at 14:00, shops until 16:10, and exits on a two-hour-old price.

FlashThe availability count

One listener adjusts on parked and freed, and a periodic recount repairs drift, because a maintained counter accumulates errors and never self-corrects. The sign is advice; claim is the only authority.

Next: 9.7.29 — expense sharing, where the resource being handed out is money owed between people, and a lost paisa is a bug you can be fired for.