The parking lot is the canonical OOD problem because it hides three moving policies (a fit rule, a fee rule, and a two-cars-one-spot race), and the skill is turning changeable rules into data behind narrow interfaces.
Interface first
| Call | In | Out | Side effect |
|---|
park(vehicle) | plate + size | Ticket or None | one spot occupied, ticket opened |
leave(ticket_id) | id string | int cents owed | ticket closes, spot freed |
free_count(kind) | spot kind | int | none (pure read) |
- Money is always integer cents, never a float (binary float cannot hold one tenth exactly).
park is the only operation with an interesting precondition (a fitting free spot must exist), so it is what the design is about.
Noun triage: what becomes a class
- Class (has behaviour or lifecycle):
ParkingLot, Level, Spot, Ticket.
- Value object (frozen dataclass, identity is its contents):
Vehicle. Not Ticket, which is issued then closed.
- Enum (closed set, no behaviour):
VehicleSize, SpotKind.
- Strategy (a rule, not a thing):
FitRule, FeeModel.
- Subclass only when a method differs. No
Car/Truck or CompactSpot/LargeSpot: they differ only in one field.
Two strategies, split by who changes them
- Strategy = a policy in its own object with one fixed method, swappable without touching callers.
FitRule.fits(size, kind) -> bool: the fit policy is not a total order (compact-in-large yes, motorcycle-in-large no), so a <= comparison cannot express it. Use a table; a policy change is one set member.
FeeModel.fee_cents(hours) -> int: split from the fit rule because finance owns money and operations owns layout. Different requesters, different cadence. max(1, hours) bills any part of an hour as an hour.
- Split strategies by their source of change requests, not by how the code looks.
The race: two cars, one spot
- Race condition: find-a-spot and claim-it are two steps; between them another thread reads the same free spot. Both write, one car is silently overwritten, invariant “one vehicle per spot” breaks.
- Fix: wrap find-and-claim in one critical section with a
threading.Lock (with self._lock:). One winner, one honest “lot full” rejection.
- A lock guards code, not data:
Spot.vehicle is still a public field, so the invariant holds by convention, not construction.
park(vehicle)
└─ acquire lock ──┐ critical section
scan for fit │ (find AND claim
claim + ticket │ are inseparable)
── release lock ──┘
Size the lock before splitting it
- ~1,000 spots x ~20 ns = ~20 µs held; ~2 arrivals/s. Lock busy ~0.004% of the time, so 3 entrances never contend.
- Finer options (per-spot atomic claim, per-(level,kind) deque, per-level lock) win only at thousands of arrivals/s, and each costs “nearest free spot” ordering or an exact free count.
- One process only: a
threading.Lock is invisible to a second server. Cross-process, move the claim into the DB: UPDATE spots SET vehicle=? WHERE id=? AND vehicle IS NULL (one atomic check-and-write).
Extensions: what each bet costs
| Change | Absorbed? | Why |
|---|
| EV bays | Yes | Enum member + one SizeFit.ALLOWED row + EnergyFee decorator (wraps a FeeModel, adds energy). park never edited |
| Price by time of day | Partly | Class boundary holds, but fee_cents(hours) signature is too narrow; must widen to take the ticket’s window. Store timestamps, not a duration |
| Monthly reservations | No | Availability was modelled as a null check (2 booleans); a third/fourth state needs a SpotState enum + available_to(pass_id), editing 4 files |
Gotchas
- Statement order in
leave: compute everything that can fail (the fees[size] lookup can raise KeyError), then mutate. Fail before the first write = a retry; fail after = a lost spot and ticket.
- A new
VehicleSize plus its fees row are two edits nothing links; a missing key raises KeyError at the barrier. Guard with a startup check that every enum member has an entry.
- Store the raw observation (two timestamps) and compute the rollup; timestamps can never be recovered from a duration.
free_count deliberately skips the lock: a display board tolerates a stale count; park does not.
- The frozen thing (a signature, a field type, a null check) is the bet most likely to move. Know which bets you placed and what each losing case costs.