InterviewPrepKit

Home / Learn / Object-Oriented Design

04 — Parking Lot System

“Design a parking lot” is the canonical first object-oriented design (OOD) problem because three hard things hide inside it:

This chapter works the problem end to end: from the prompt to roughly ninety lines of running Python, three extensions, and the one extension the design fails to absorb. By the end you can build the model, defend each relationship in the diagram, reproduce the two-car race in code and fix it, and state what the class structure assumed and what a different assumption would have produced.

Every code block runs on a stock Python 3.9 install using only the standard library.

Two terms carry the design.

Object-oriented design means deciding which things become objects, what each one is allowed to know, and where each rule lives.

A strategy is one such decision applied to a rule. Instead of writing a policy as an if chain inside the code that uses it, you put the policy in its own small object with one fixed method. The policy can then be swapped without touching its caller. Two of this chapter’s three key decisions are strategies.

What goes in and what comes out

Fix the interface before writing any classes. A reader should be able to say what goes in and what comes out before hearing anything about mechanism.

The system exposes three operations. The last column is the part that makes each operation hard to undo, and it is the reason park gets the lock later on.

CallInputOutputSide effect
park(vehicle)a Vehicle: a licence plate string plus a sizea Ticket, or None when nothing fitsone spot becomes occupied, one ticket opens
leave(ticket_id)the ticket’s id stringan integer number of cents owedthe ticket closes, the spot is freed
free_count(kind)a spot kindan integernone; this is a pure read for the display board

Every number below is asserted in code further down, in the assembled core.

Money is an integer count of cents everywhere in this design, never a float. Binary floating point cannot represent one tenth exactly, so a fee system that adds tenths eventually disagrees with itself. The reasoning is worked out in 03.

This chapter runs the six steps of the interview framework in 02 (clarify, actors, objects, decisions, code, extend), and the object-oriented vocabulary it leans on (substitutability, single responsibility, strategy objects versus switch statements) is developed in 03.

Clarifying questions that change the design

The questions worth asking are those whose answers change what you build. Each row below is one such question: the answer this chapter commits to, and what the other answer would have forced you to build instead.

QuestionThe answer I will assumeThe fork it creates
Can a small vehicle use a big spot?Yes for compact-in-large, no for motorcycle-in-largeThis is the fit rule (decision 1). If the answer is a total order, one comparison works; if it has exceptions, it is a table
Can two motorcycles share one spot?NoOccupancy is a boolean, not a count. “Yes” makes Spot hold a list and changes every free check
How is the fee computed?First hour flat, then hourly, by vehicle sizeA rule that finance owns and changes. Separate object (decision 2)
One entrance or several?3 entrances, 5 levels, 1,000 spotsConcurrent assignment. This is the only reason there is a lock in this design
Payment, receipts, refunds?Out of scope; leave() returns an amount owedBuys ten minutes. Say where the gateway plugs in
Reservations or monthly passes?Not todayDeliberately deferred, and revisited below as the change that hurts
Persistence?In memoryA repository protocol behind the lot, named but not written
Do we need “where did I park”?Yes, from the ticketThe ticket holds a spot reference, so lookup is O(1) and not a scan

Three of those rows use words worth defining.

Repository. An object that stands between the model and storage. The rest of the design asks it to save and fetch spots and tickets, and never knows whether the answer came from a database, a file, or a dictionary in memory.

Total order. Every pair of sizes can be ranked on one line, such that “smaller fits in bigger” is always true. The fit rule turns out not to be a total order, and that fact is the whole of decision 1.

O(1), read “order one”. Notation for “the work does not grow with the size of the collection”. Looking up where you parked by following a reference stored on the ticket takes the same time whether the garage holds ten cars or ten thousand. Scanning every spot to find your plate takes a thousand times longer in a thousand-spot garage.

One more distinction decides what kind of round this is. The 1,000 spots and 3 entrances are cardinalities, not capacity. Cardinality means how many of these exist, a fact about the shape of the model. Capacity is a throughput figure the hardware has to sustain.

Nothing in this chapter is sized in queries per second (QPS), the usual unit for how much traffic a service handles. A garage network across 500 sites would be a different round (sd 03).

Actors and use cases

Who touches the system and what each one can do. The operation with the most interesting precondition is the one the design is really about.

An actor is anyone or anything that initiates an operation: a human role, another system, or the clock. A precondition is what must already be true for the operation to succeed. An empty precondition means the operation cannot fail and therefore is not interesting.

ActorOperationPreconditionChanges
Driverpark(vehicle)a free spot that fits existsspot occupancy, new ticket
Driverleave(ticket_id)ticket is openticket state, spot freed, amount owed
Attendantclose(spot_id)spot is freespot state
Display boardfree_count(kind)nonenothing
Clockexpire an unclaimed holdhold window passedreservation state (extension 3)

park is the only operation with an interesting precondition, so it is the method the whole design is about. Everything else is a read or a state flip. Making that observation in an interview tells the interviewer where you will spend your time.

Two rows in that table are promises the code below only half keeps. The attendant’s close(spot_id) never becomes a method: the assembled core gives Spot an in_service flag that is set when the spot is constructed, and nothing exposes a way to flip it later. The clock’s expiry job belongs to extension 3 and is described there, not implemented. Both are deliberate scope cuts.

The core objects, and why those

The prompt’s nouns become classes, but most of the design judgement lives in the nouns that deliberately do not.

The technique is noun triage: list the nouns in the problem statement, then decide for each whether it is a class with behaviour, a value with no identity, an enumeration, or nothing at all (see 02).

The third column names the design you did not pick and what it would have cost.

ObjectKindWhy not the obvious alternative
ParkingLotclassOwns assignment and the lock. Not a “manager” - it has one job, matching vehicles to spots
LevelclassOwns “which of my spots is free”. Without it, ParkingLot scans a flat list and the display board cannot report per floor
SpotclassNot CompactSpot/LargeSpot subclasses. They would differ only in a constant, and the fit decision would then be spread across three classes
Vehiclefrozen dataclassNot Car/Truck/Motorcycle subclasses. They have identical behaviour; the difference is one field. Subclass only when a method differs
TicketclassHas a lifecycle - issued, closed - so it is not a value object. It stores timestamps, not a duration
VehicleSize, SpotKindenumsClosed sets, compared by value, no behaviour
Moneyinteger centsA float fee is a rounding bug (03)
FitRulestrategyA rule, not a thing. Decision 1
FeeModelstrategyA rule, not a thing, changing on a different schedule than the fit rule. Decision 2

Three of those kinds need a definition.

Frozen dataclass. A Python class built by the @dataclass(frozen=True) decorator. The decorator writes the constructor and the equality test for you from the field names you list, and frozen=True makes instances immutable, so assigning to a field after construction raises an error.

Value object. An object with no identity of its own, whose whole meaning is its contents. Two vehicles with the same plate and size are simply the same vehicle. A frozen dataclass is the natural Python spelling of one, which is why Vehicle is written that way.

Ticket is deliberately not a value object. It is issued, then closed, so it has a lifecycle, and its identity outlives any particular set of field values. Two tickets with identical fields are still two different tickets.

Enum, short for enumeration. A fixed, named set of constants such as VehicleSize.COMPACT. Use one when the set is closed and the members carry no behaviour. The payoff is that a typo becomes an AttributeError at import time instead of a string comparison silently returning False at runtime.

Two “why not” answers to have ready

“Why isn’t a Spot just a boolean in an array?” For 1,000 spots it could be, and the assignment scan would be faster. But the spot has identity the driver needs printed on the ticket (L3-014), and it will grow a state beyond free/taken the moment maintenance or reservations appear. The object boundary does not force the representation: Level may hold a bitset internally, meaning one bit per spot packed into an integer, and still hand out Spot objects to everyone else.

“Why doesn’t Ticket compute its own fee?” Because pricing changes quarterly and is owned by finance, while the ticket’s lifecycle is owned by operations. One class with two actors changing it is the single-responsibility violation from 03. A Ticket that prices itself also needs a rate table, so every test that constructs a ticket now needs a rate table too.

What this class structure assumes

The specific parking lot is not the part that transfers; the habit of reading a design as a set of bets about what will change is. Every arrangement of classes encodes an assumption about the future.

Whatever you made a parameter, a strategy object, a table entry or an enum member, you assumed varies. Variation is cheap there: add a row, pass a different object.

Whatever you made a method signature, a field type, or the absence of a value, you assumed is fixed. Variation is expensive there, because changing it edits every implementation and every caller at once.

First the cheap side, the things you can change without opening ParkingLot:

Assumed to vary (cheap to change)How it shows up in the code
Which vehicle sizes fit which spot kindsA FitRule object holding a table, injected into the lot
The price of a stayA FeeModel object per vehicle size, injected into the lot
How many levels and spots exist, and of which kindsConstructor arguments; nothing is hard-coded
What time it isAn injected clock function, so tests do not wait three hours
The set of vehicle sizes and spot kindsEnum members plus one table row each

Now the expensive side. Each row names a fact the code cannot bend around; changing it means editing several files at once:

Assumed fixed (expensive to change)Where the assumption is welded in
One vehicle per spotSpot.vehicle is a single optional field, not a list; park filling it and leave clearing it are the only writes by convention, not by construction
“Free” means “nobody is parked here and the spot is open”Two booleans ANDed together inside Spot.is_free: self.in_service and self.vehicle is None. Level.first_free and free_count never repeat that test, they consume its answer
A stay is one contiguous interval priced from its lengthfee_cents(hours) takes a single number of hours
All state lives in one processA threading.Lock is the entire concurrency story
Spots differ only in data, never in behaviourOne Spot class, no subclasses
A vehicle is fully described by a plate and a sizeOne frozen Vehicle dataclass with two fields

The second row matters because extension 3 collects on it. Availability is genuinely a multi-state question, and this design answers it with two independent booleans added at different times. That is the shape of a model that will need replacing rather than extending.

For each bet, here is the design a different assumption would have produced. This is the column an interviewer is really asking about when they say “what would you change?”:

If instead you assumedThe design that falls out
Two motorcycles may share a spotSpot holds a list of vehicles plus a capacity, is_free becomes has_room_for(size), and the fit rule becomes a packing rule that must consider what is already parked there
Spots differ in behaviour, not just dataCompactSpot and LargeSpot subclasses, and the fit decision spreads across every subclass instead of living in one table
Availability has more than two statesThe SpotState enum of extension 3 from day one, instead of a null check; this is the assumption that turns out to be wrong
Pricing may depend on when, not just how longFeeModel takes the ticket’s entry and exit timestamps rather than a duration, which is exactly the signature repair of extension 2
Two garage servers share one inventoryThe claim moves out of the process entirely and into a conditional database update; ParkingLot becomes a thin service over a repository and the in-process lock disappears
Vehicles carry more than a plate and a sizeVehicle grows fields and probably stops being frozen, and any code that compared two vehicles by value has to be revisited

The failure mode is always the same shape: the thing you froze is the thing that moved. You cannot dodge it by freezing nothing; a model that assumes nothing is a dictionary of dictionaries and prevents no bug at all. The skill is to know which bets you placed and what each losing case costs, which is why extension 3 below is written up as a loss rather than hidden.

Class diagram

In this notation every relationship is a claim that can be wrong, which makes the model worth drawing and then defending line by line.

Nine boxes and ten lines follow. If the notation is unfamiliar, read How to read the notation first. The two lines to look at first are the filled diamond on Level *-- Spot and the hollow diamond on Spot o-- Vehicle: those two say who owns whom, and one of them is the most commonly botched claim in this problem.

classDiagram
    class ParkingLot {
        -Lock lock
        +park(Vehicle) Ticket
        +leave(str) int
        +free_count(SpotKind) int
    }
    class Level {
        +int number
        +first_free(VehicleSize, FitRule) Spot
    }
    class Spot {
        +str id
        +SpotKind kind
        +is_free() bool
    }
    class Vehicle {
        +str plate
        +VehicleSize size
    }
    class Ticket {
        +int entry_h
        +int exit_h
    }
    class FitRule {
        <<interface>>
        +fits(VehicleSize, SpotKind) bool
    }
    class SizeFit
    class FeeModel {
        <<interface>>
        +fee_cents(int) int
    }
    class HourlyFee

    ParkingLot "1" *-- "1..*" Level : composition
    Level "1" *-- "1..*" Spot : composition
    ParkingLot "1" *-- "0..*" Ticket : owns open tickets
    ParkingLot "1" --> "1" FitRule : injected
    ParkingLot "1" --> "1..*" FeeModel : one per size
    Ticket "1" --> "1" Spot : association
    Ticket "1" --> "1" Vehicle : association
    Spot "0..1" o-- "0..1" Vehicle : parked in
    FitRule <|.. SizeFit : implements
    FeeModel <|.. HourlyFee : implements

That is a Unified Modeling Language (UML) class diagram. Decode it piece by piece.

How to read the notation

Boxes are types. The name sits at the top; fields and methods sit underneath.

A leading + means the member is public: callers may use it. A leading - means private. That is why ParkingLot shows -Lock lock: the lock is an internal mechanism nobody outside may touch.

The member lists are abridged. Each box shows only the members this chapter argues about, so Ticket lists its two timestamps and not its id, vehicle or spot, even though the code has all of them. Spot.is_free() is drawn as a method because UML has no separate notation for it; in Python it is a @property, so callers write spot.is_free with no parentheses.

SizeFit and HourlyFee are drawn as bare boxes with no member list because their members are exactly the ones their interfaces already declare.

The <<interface>> marker is a stereotype, UML’s way of labelling a box as a set of methods somebody else must supply, rather than a class with code in it. FitRule promises fits(VehicleSize, SpotKind) bool and FeeModel promises fee_cents(int) int. Neither says how.

Lines are relationships, and the shape of the line is the claim. There are four shapes in this diagram:

Drawn asCalledMeans
*-- filled diamondcompositionthe whole owns the part; destroy the whole and the part goes with it
o-- hollow diamondaggregationthe whole holds the part for a while, but the part has a life of its own
--> plain arrowassociationthis type holds a reference to that one, with no ownership claim
dashed arrow, hollow triangular headimplementspoints from the implementing class to the interface it satisfies

In the diagram source above, that last one is the FitRule <|.. SizeFit line: read the arrowhead as pointing at the promise, and the tail as the class that keeps it.

The quoted strings are multiplicities: how many objects sit at each end. "1" means exactly one, "1..*" means one or more, "0..*" means any number including none, and "0..1" means at most one.

So ParkingLot "1" *-- "1..*" Level reads: one parking lot owns one or more levels.

Defending each relationship

Each relationship is a falsifiable claim, and an interviewer may ask you to defend one:

Decision 1 — the fit rule is a strategy

The most tempting shortcut in this problem fails to a requirement change that arrives in week two. What replaces it is a rule object, and what the replacement buys can be counted rather than asserted.

The shortcut that fails first

The tempting version orders the sizes as numbers and compares them:

from enum import IntEnum


class Size(IntEnum):
    """One ordering shared by vehicles and spots - already a smell."""
    MOTORCYCLE = 1
    COMPACT = 2
    LARGE = 3


def fits_bad(v: Size, s: Size) -> bool:
    return v <= s                       # "anything fits a bigger spot"


assert fits_bad(Size.COMPACT, Size.LARGE)
assert fits_bad(Size.MOTORCYCLE, Size.LARGE)     # <- the policy we are about to lose
assert not fits_bad(Size.LARGE, Size.COMPACT)

IntEnum is an enumeration whose members really are integers, so Size.COMPACT <= Size.LARGE is just 2 <= 3. That is what makes the shortcut attractive: the fit rule collapses into one comparison operator, and all three asserts pass.

Then the first requirement change lands: stop putting motorcycles in large spots, because the garage loses a truck fare every time.

That policy is not a total order. There is no numbering of three sizes under which compact-fits-large is true and motorcycle-fits-large is false, because both statements are of the form “smaller number fits bigger number” and a single <= cannot accept one and reject the other. So fits_bad cannot be patched. It has to be replaced, along with every call site that assumed the comparison was the rule.

The rule as an object

The repair is to stop expressing the policy as arithmetic and start expressing it as data, behind a small interface:

from enum import Enum
from typing import Protocol


class VehicleSize(Enum):
    MOTORCYCLE = "moto"
    COMPACT = "compact"
    LARGE = "large"


class SpotKind(Enum):
    MOTORCYCLE = "moto"
    COMPACT = "compact"
    LARGE = "large"


class FitRule(Protocol):
    def fits(self, v: VehicleSize, s: SpotKind) -> bool: ...


class SizeFit:
    """One table. The policy is visible in six lines and changes in one place."""

    ALLOWED = {
        VehicleSize.MOTORCYCLE: {SpotKind.MOTORCYCLE, SpotKind.COMPACT},
        VehicleSize.COMPACT: {SpotKind.COMPACT, SpotKind.LARGE},
        VehicleSize.LARGE: {SpotKind.LARGE},
    }

    def fits(self, v: VehicleSize, s: SpotKind) -> bool:
        return s in self.ALLOWED[v]


rule = SizeFit()
assert rule.fits(VehicleSize.COMPACT, SpotKind.LARGE)
assert not rule.fits(VehicleSize.MOTORCYCLE, SpotKind.LARGE)     # the new policy
assert not rule.fits(VehicleSize.LARGE, SpotKind.COMPACT)
print("fit rule: policy change is one set member")

Four things in that block deserve a note.

Enum, not IntEnum. These members are not comparable as numbers. Removing the ordering removes the temptation to write <= again.

Protocol is Python’s structural interface. FitRule declares the method fits and nothing else. Any class that happens to have a matching fits method satisfies it, without inheriting from it and without registering anywhere. That is why SizeFit never mentions FitRule in its class statement.

The ... in the protocol body is the literal Ellipsis token. It stands in for “no implementation here”.

ALLOWED maps each vehicle size to the set of spot kinds it may use. The banned motorcycle-in-large case is a member that is not in a set. The table as shown is already the post-change version: under the old policy the first row read {MOTORCYCLE, COMPACT, LARGE}, and deleting one set member was the entire edit, one line in one file with no call site touched.

Counting what the table bought

The block below is arithmetic, not code. It sizes the rule today and then again after the electric-vehicle extension of extension 1.

vehicle sizes today                                    3
spot kinds today                                       3
pairs the rule must answer      3 x 3              =   9
add EV as a fourth kind         4 x 4              =   16
new pairs to place by hand      16 - 9             =   7

Three vehicle sizes against three spot kinds is 3 x 3 = 9 yes-or-no answers the rule must give. Add electric as a fourth size and a fourth kind and the grid becomes 4 x 4 = 16, so 16 - 9 = 7 new pairs need a decision. In the hand-written version each of those decisions is a branch.

Seven hand-written branches versus one row in a table, and nothing tells you when you have missed one of the seven, because a missing branch is a silent False.

Count the call sites honestly, because this is where the argument is usually inflated. This model asks the fit question in exactly one place, Level.first_free. So seven is seven, not a multiple of it. The table does not save twenty edits today.

The multiplier arrives with the second caller: a display board that reports “spots a truck could use”, or a pre-booking check at the barrier. Then it is 7 branches per call site, with no compiler and no test telling you which site you forgot. One call site today, and the table is what makes the second one free.

What it costs. The fit logic is no longer visible where assignment happens. You read rule.fits(...) and have to open another class to know what it does.

A wrong table entry is also silent (trucks in bike bays, no exception raised), so the rule now needs its own unit test, which the inline if did not. That trade is worth it here because the policy is known to change. It would not be worth it for a rule nobody has ever asked to modify.

The assumption this encodes. FitRule assumes the policy varies while the question stays fixed: given one vehicle size and one spot kind, yes or no. Everything the rule could need is those two values. Change that assumption and the interface breaks rather than bends: if fit ever depends on what is already parked nearby (two motorcycles sharing a bay, an oversized vehicle overhanging its neighbour), then fits(size, kind) cannot express it, and editing the table does not help. That is a signature change, the same class of mistake made deliberately in decision 2.

Decision 2 — the fee is a separate strategy

Pricing gets its own object rather than sharing one with the fit rule. The boundary between them is drawn by who asks for changes, not by what the code looks like.

The reason for two strategies is the single-responsibility test by actor from 03: a class should have one source of change requests.

The fit rule changes when operations changes its mind about layout. The fee changes when finance changes its mind about money. Different requesters, different release cadence, different tests.

Merge them into one ParkingPolicy object and every pricing change re-tests spot assignment. That is the concrete cost of the merge, and the answer to give when an interviewer asks why not one policy object.

The fee model is small. Read it for two things: the signature fee_cents(hours) -> int (hours in, cents out) and the max(1, hours) that implements “any part of an hour is an hour”.

from dataclasses import dataclass
from typing import Protocol


class FeeModel(Protocol):
    def fee_cents(self, hours: int) -> int: ...


@dataclass(frozen=True)
class HourlyFee:
    first_hour_cents: int
    extra_hour_cents: int

    def fee_cents(self, hours: int) -> int:
        billable = max(1, hours)                 # any part of an hour is an hour
        return self.first_hour_cents + (billable - 1) * self.extra_hour_cents


compact = HourlyFee(first_hour_cents=400, extra_hour_cents=250)
assert compact.fee_cents(1) == 400
assert compact.fee_cents(3) == 900               # 400 + 2 x 250
assert compact.fee_cents(0) == 400               # a 5-minute stay still pays one hour
print("fee model: hours in, cents out, nothing else")

@dataclass(frozen=True) gives HourlyFee a generated constructor taking its two fields, and makes the result immutable. That is what you want of a price list: nothing should be able to mutate the rate after the lot is built.

Walk the three asserts. One hour is the flat 400. Three hours are 400 + 2 x 250 = 900: the first hour flat, then two extra hours. A zero-hour stay is 400 too, because max(1, hours) floors the billable count at one.

Note what fee_cents does not take. It knows nothing about spots, tickets, or vehicles, which is what makes the time-of-day extension below cheap.

What it costs. The money path now spans two files. The model is selected from a dictionary keyed by vehicle size, so a missing key raises a KeyError at exit time, with a driver sitting at the barrier.

That is the worst moment for a runtime failure. Two guards close it: a default model for unknown sizes, and a startup check that every enum member has an entry. Neither is in the code below, which is why the ordering of statements inside leave has to do the work instead.

The assumption this encodes. FeeModel assumes the pricing rule varies while the input is fixed at a single number of hours. Extension 2 loses that bet: the moment price depends on which hours rather than how many, the signature is too narrow and every implementation has to change. Had the assumption been “price depends on the stay”, the method would have taken the ticket’s window from the start and the extension would have been free.

Decision 3 — the race: two cars, one spot

This is why the clarifying question about entrances mattered: three entrances mean concurrent calls into park. The two-car bug is reproduced deterministically below, fixed with one lock, and then three fancier fixes are refused on the strength of a number.

The failing interleaving

Three entrances, one free spot, two drivers pressing the button in the same millisecond.

A thread is an independent flow of execution inside one process. Three entrances handling requests at the same time means three threads reading and writing the same Spot objects.

The table below traces what goes wrong. The rightmost column is the shared state after each step, and the bolded cell is the moment the garage’s records stop matching reality.

StepThread A (north gate)Thread B (south gate)Shared state
1scans, finds L1-1 freeL1-1.vehicle = None
2scans, finds L1-1 freeL1-1.vehicle = None
3writes L1-1.vehicle = car_aL1-1 holds A
4writes L1-1.vehicle = car_bL1-1 holds B
5prints ticket for L1-1prints ticket for L1-1two tickets, one spot

The same interleaving as a timeline, where the gap between each thread’s read and its write is the window that lets the other thread slip in:

sequenceDiagram
    participant A as Thread A (north gate)
    participant S as Spot L1-1
    participant B as Thread B (south gate)
    A->>S: read, sees free
    B->>S: read, sees free
    A->>S: write, claim for A
    B->>S: write, claim for B (overwrites A)
    Note over S: spot holds B, two tickets exist

That interleaving is a race condition: two threads touch the same data and the outcome depends on which one happens to run first.

The invariant that breaks is “one vehicle per spot”, and it breaks because find-a-free-spot and claim-it are two separate operations with a window between them.

Car A’s record is silently overwritten. The system now believes A is not in the garage, so A’s exit fails, and B is billed for a spot that physically contains two cars.

Reproducing it deterministically

A race that shows up one run in a thousand cannot be demonstrated in an interview or pinned down with a test, so force it.

A barrier is a synchronisation device that makes each arriving thread wait until a set number of them have arrived. Used here, it holds both threads inside the dangerous window at the same time, instead of hoping the operating system schedules them badly.

In the block below, the two lines that matter are marked # READ and # WRITE. Everything between them is the window.

import threading
from dataclasses import dataclass
from typing import Callable, Optional


@dataclass
class Slot:
    id: str
    holder: Optional[str] = None


class UnsafeLot:
    """find() and claim() are separate. The hook makes the window visible."""

    def __init__(self, slots: list[Slot], hook: Callable[[], None]) -> None:
        self.slots = slots
        self.hook = hook

    def park(self, plate: str) -> Optional[Slot]:
        free = next((s for s in self.slots if s.holder is None), None)   # READ
        if free is None:
            return None
        self.hook()                                                      # window
        free.holder = plate                                              # WRITE
        return free


gate = threading.Barrier(2)
lot = UnsafeLot([Slot("L1-1")], hook=gate.wait)
got: dict[str, Optional[Slot]] = {}

threads = [threading.Thread(target=lambda n=n: got.__setitem__(n, lot.park(n)))
           for n in ("AAA-111", "BBB-222")]
for t in threads:
    t.start()
for t in threads:
    t.join()

assert got["AAA-111"] is got["BBB-222"]          # both were handed the same spot
lost = [p for p in got if p != lot.slots[0].holder]
assert len(lost) == 1                            # the loser's car is now invisible
print(f"race: two tickets for {lot.slots[0].id}, {lost[0]} is unaccounted for")

Five idioms carry that block.

Run it and both threads are handed the same Slot object. The first assert compares them with is (identity, not equality), so it checks that they got the same object, not two equal ones. Exactly one plate then ends up unaccounted for, which the last two lines measure.

The fix, and why the coarse lock is the right one

Make find-and-claim a single critical section: a stretch of code that only one thread may execute at a time. A lock (or mutex) is the object that enforces that: one thread acquires it, others wait, and the winner releases it on the way out.

SafeLot below changes one line inside park: with self._lock: now wraps the body, so the find and the claim happen together.

The harness under it changes too. The barrier is gone, replaced by time.sleep. A barrier would now deadlock: a state where every thread is waiting for something only another waiting thread can supply. B can never reach the barrier, because A is inside the lock and will not release it until the barrier lets it out. Instead, A is started first and given a 10-millisecond head start while its hook sleeps for 50, which guarantees B arrives to find the lock held.

import time                                        # Slot as defined above


class SafeLot:
    def __init__(self, slots: list[Slot], hook: Callable[[], None]) -> None:
        self.slots = slots
        self.hook = hook
        self._lock = threading.Lock()

    def park(self, plate: str) -> Optional[Slot]:
        with self._lock:                                   # find AND claim
            free = next((s for s in self.slots if s.holder is None), None)
            if free is None:
                return None
            self.hook()
            free.holder = plate
            return free


lot = SafeLot([Slot("L1-1")], hook=lambda: time.sleep(0.05))
got: dict[str, Optional[Slot]] = {}


def drive(plate: str) -> None:
    got[plate] = lot.park(plate)


a = threading.Thread(target=drive, args=("AAA-111",))
b = threading.Thread(target=drive, args=("BBB-222",))
a.start()
time.sleep(0.01)                                  # B arrives while A holds the lock
b.start()
a.join()
b.join()

winners = [p for p, s in got.items() if s is not None]
assert winners == ["AAA-111"]                     # exactly one ticket
assert got["BBB-222"] is None                     # the other is told the lot is full
assert lot.slots[0].holder == "AAA-111"
print("lock: one spot, one winner, one honest rejection")

The with self._lock: block acquires the lock on entry and releases it on exit, including when the body returns early or raises. That is why the two return statements inside are safe, and the reason to write with rather than an explicit acquire()/release() pair.

The outcome changes from “two tickets for one spot” to “one ticket and one honest refusal”. B is told the lot is full, which is true at the moment it asks.

What that bought is narrower than it sounds. A lock guards a stretch of code, not a piece of data.

It removes the interleaving between park and park. It does nothing about code that writes slot.holder without going through park at all: holder is still a public field on a mutable object.

The invariant survives here because park is the only method that writes it, a convention enforced by review rather than by the runtime. The assembled core below has a test that breaks that convention on purpose and shows what it costs.

Sizing the lock before splitting it

The instinct after adding a lock is to worry that it is too coarse and reach for something finer. Resist that until you have a number.

The block below derives one. It is arithmetic, not code. The top half sizes how long the lock is held; the bottom half sizes how often it is taken; the last line multiplies them.

spots scanned, worst case                            1,000
per-spot check, ns (estimate)                           20
critical section, ns       1,000 x 20             =  20,000

turnovers per spot per day (estimate)                    3
arrivals per day           1,000 x 3              =   3,000
share of the day arriving in the busiest hour          20%
arrivals in that hour      3,000 x 0.20           =     600
arrivals/s at peak         600 / 3,600            =    0.17
arrivals/s used below, rounded up for headroom           2

lock busy fraction         2 x 0.000020           =  0.00004

Walk the derivation line by line, flagging which figures are measured and which are guessed.

How long the lock is held. The worst case is scanning all 1,000 spots and finding no fit. Each check is a couple of attribute reads and a set membership test, call it 20 nanoseconds. That is an estimate, and the one number here worth checking on real hardware. So the whole critical section is 1,000 x 20 = 20,000 nanoseconds, which is 20 microseconds, or 0.000020 seconds.

How often it is taken. The arrival rate comes from the same 1,000 spots. Nothing in the prompt gives you a rate, so derive one from the cardinality rather than asserting it.

A commuter garage turns each spot over about 3 times a day (morning shift, afternoon, evening), which is 1,000 x 3 = 3,000 arrivals a day.

Those do not spread evenly. Put a generous 20% of them into the single busiest hour: that hour takes 600 arrivals, or 600 / 3,600 = 0.17 a second.

Round 0.17 up to 2. That is more than ten times the derived peak, so the number you are about to refuse complexity on already carries an order of magnitude of headroom.

Multiply. The lock is held 2 x 0.000020 = 0.00004 of the time.

The lock is held for 20 microseconds and taken twice a second, so it is busy 0.004% of the time. Three entrances will never contend.

Given that, the fancier options buy nothing measurable. A per-spot compare-and-swap (an atomic instruction: one that completes in a single indivisible step no other thread can observe half-finished, and which writes a value only if the current value is the one you expected) or a lock-free free-list would each cost the “nearest free spot” ordering and turn the free count into an approximation.

Name the alternatives anyway, because the follow-up is usually “and if that were not true?”. The middle column is the condition that would flip the decision:

AlternativeWhen it winsWhat it costs
Per-spot atomic claim, retry on failureThousands of arrivals per second, or a scan long enough to matterpark can fail spuriously and must retry; the free count becomes approximate
Pre-partitioned free pool per (level, kind), deque.popleft()Same, and it is atomic in CPython, the standard Python implementation, with no lock at allLoses “nearest to the entrance” ordering unless the deque is kept sorted
One lock per level instead of per lotMany levels and heavy contentionA vehicle that fits nowhere must probe every level lock, and free-count across levels is no longer a consistent snapshot

The second row needs one gloss: a deque is a double-ended queue, and popleft() removes its first element in a single operation that CPython performs atomically, so two threads cannot both get the same element. Pre-sorting free spots into one such queue per (level, kind) pair turns “find and claim” into one atomic pop, at the cost of the ordering the scan gave for free.

The limit of the lock

Stating the limit unprompted scores well.

This lock protects one process. A load balancer spreads incoming requests across several identical servers, and the consequence here is blunt: a Python threading.Lock living in server A is invisible to server B. Two garage servers behind one load balancer share nothing.

So the claim has to move to where the state actually lives, the database:

UPDATE spots SET vehicle = ? WHERE id = ? AND vehicle IS NULL

That single statement is the same critical section, expressed one layer down. The database applies the WHERE clause and the write as one atomic step, and reports how many rows it changed. One row means you claimed the spot. Zero rows means somebody else got there first, and you scan again. The AND vehicle IS NULL does the job with self._lock: did in SafeLot: it makes the check and the write inseparable.

The assumption this encodes. The lock assumes all state lives in one process and that one thread at a time is fast enough. Both are true at 2 arrivals per second and both are false the moment you run a second server, which is why the database statement above is the same design expressed under a different assumption.

The assembled core

Everything above now wires into one runnable model, exercised with the concrete inputs and outputs promised at the top of the chapter.

Reading the model

The block below is about sixty lines and defines five types. Read them bottom-up: ParkingLot is where all the decisions land, and the four types above it are the data it moves around.

Four things to notice:

  1. Spot.is_free is a @property, so it is called without parentheses, and it ANDs two flags.
  2. park holds the lock across both the search and the write. That is decision 3 in its real setting.
  3. leave reads the ticket without removing it, prices the stay, and only then mutates anything. The order is deliberate and is dissected below.
  4. ParkingLot.__init__ constructs none of its collaborators: levels, fit rule, fee table and clock all arrive from outside.

That last point includes the clock. It is injected as a function rather than read from time, so the fee test can move three hours forward instantly instead of waiting three hours.

import threading
from dataclasses import dataclass, field
from typing import Callable, Optional


@dataclass(frozen=True)
class Vehicle:
    plate: str
    size: VehicleSize


@dataclass
class Spot:
    id: str
    kind: SpotKind
    vehicle: Optional[Vehicle] = None
    in_service: bool = True

    @property
    def is_free(self) -> bool:
        return self.in_service and self.vehicle is None


@dataclass
class Level:
    number: int
    spots: list = field(default_factory=list)

    def first_free(self, size: VehicleSize, rule: FitRule) -> Optional[Spot]:
        return next((s for s in self.spots
                     if s.is_free and rule.fits(size, s.kind)), None)


@dataclass
class Ticket:
    id: str
    vehicle: Vehicle
    spot: Spot
    entry_h: int
    exit_h: Optional[int] = None
    paid_cents: Optional[int] = None


class ParkingLot:
    def __init__(self, levels: list, fit: FitRule,
                 fees: dict, now: Callable[[], int]) -> None:
        self._levels = levels
        self._fit = fit
        self._fees = fees
        self._now = now
        self._lock = threading.Lock()
        self._open: dict = {}
        self._seq = 0

    def park(self, vehicle: Vehicle) -> Optional[Ticket]:
        with self._lock:
            for level in self._levels:
                spot = level.first_free(vehicle.size, self._fit)
                if spot is None:
                    continue
                spot.vehicle = vehicle                      # claim, same section
                self._seq += 1
                t = Ticket(id=f"T{self._seq}", vehicle=vehicle,
                           spot=spot, entry_h=self._now())
                self._open[t.id] = t
                return t
            return None

    def leave(self, ticket_id: str) -> int:
        with self._lock:
            t = self._open[ticket_id]                   # read, do not remove
            exit_h = self._now()
            paid = self._fees[t.vehicle.size].fee_cents(exit_h - t.entry_h)
            t.exit_h, t.paid_cents = exit_h, paid       # nothing above here mutates
            t.spot.vehicle = None
            del self._open[ticket_id]
            return paid

    def free_count(self, kind: SpotKind) -> int:
        return sum(1 for lv in self._levels for s in lv.spots
                   if s.is_free and s.kind is kind)

Six details in that block are worth stopping on.

Vehicle is frozen; Spot is not. That is the value-object-versus-lifecycle distinction made concrete. A vehicle’s plate and size never change. A spot’s occupant does.

field(default_factory=list) tells the dataclass to build a fresh empty list for each Level. Writing spots: list = [] instead would share one list between every level ever created, a classic Python trap and the reason field exists.

@property on is_free lets callers write spot.is_free with no parentheses. The payoff is that a stored flag can later become a computed answer with no caller edits, which is what extension 3 needs.

is_free ANDs two booleans, in_service and self.vehicle is None. in_service is the design’s first attempt at a third state (a spot taken out of use), bolted on as a second flag rather than folded into one availability model. Extension 3 is what happens when a fourth state arrives and there is no room left for another boolean.

ParkingLot.__init__ constructs nothing. Levels, fit rule, fee table and clock all arrive as arguments. That is dependency injection, and it is why the test below can hand in a one-key dictionary as a clock.

park holds the lock across both the search and the write, and walks levels in order, so it returns the lowest-numbered fitting spot. This is the fix from decision 3 in its real setting.

One more thing an interviewer might notice: free_count does not take the lock. That is deliberate. It is a read for a display board, so a count that is one stale by the time it renders is fine, and taking the lock would make every board refresh contend with every arrival. The board tolerates staleness; park does not.

Why the statement order inside leave is load-bearing

The lookup self._fees[t.vehicle.size] can raise KeyError. That is the cost decision 2 admitted to.

So every line that mutates state sits below it:

  1. Read the ticket out of _open, do not remove it.
  2. Read the clock.
  3. Compute the price. This is the line that can fail.
  4. Write the exit time and the amount paid.
  5. Free the spot.
  6. Delete the ticket from _open.

Now write it the other way round, as the obvious version does, with t = self._open.pop(ticket_id) on line 1. A failing exit then takes the ticket out of _open and leaves t.spot.vehicle set. The driver has no ticket the system will accept, and the spot is occupied by a car nobody can bill.

One missing dictionary key permanently loses a spot and a ticket, and no exception is raised the second time anyone looks.

The rule generalises past this method: compute everything that can fail, then mutate. Failure before the first write is a retry. Failure after it is a repair job.

That missing key is not hypothetical. It is extension 1’s hazard exactly: a new VehicleSize needs a matching fees row, and nothing in the type system requires one.

The core test

Here is the input-to-output table from the top of the chapter made executable. Every promised value ("L1-1", 0, 900, 1) appears as an assert.

clock = {"h": 0}
lot = ParkingLot(
    levels=[Level(1, [Spot("L1-1", SpotKind.COMPACT), Spot("L1-2", SpotKind.LARGE)])],
    fit=SizeFit(),
    fees={VehicleSize.MOTORCYCLE: HourlyFee(200, 100),
          VehicleSize.COMPACT: HourlyFee(400, 250),
          VehicleSize.LARGE: HourlyFee(600, 400)},
    now=lambda: clock["h"],
)

car = Vehicle("AAA-111", VehicleSize.COMPACT)
truck = Vehicle("BBB-222", VehicleSize.LARGE)
moto = Vehicle("CCC-333", VehicleSize.MOTORCYCLE)

t_car = lot.park(car)
assert t_car is not None and t_car.spot.id == "L1-1"
assert lot.free_count(SpotKind.COMPACT) == 0

t_truck = lot.park(truck)
assert t_truck is not None and t_truck.spot.id == "L1-2"

assert lot.park(moto) is None                    # no moto spot, and large is off limits

clock["h"] = 3
assert lot.leave(t_car.id) == 900                # 400 + 2 x 250
assert lot.free_count(SpotKind.COMPACT) == 1     # spot released
print("lot ok")

The clock is a one-key dictionary, and now=lambda: clock["h"] reads it. So clock["h"] = 3 is how three hours pass: no sleeping, no mocking library.

Walk the assertions in order:

Two tests that pin down the prose

Each of the next two blocks makes a claim from the prose above falsifiable. The first proves the statement ordering inside leave buys what it promises.

fees_missing_a_row: dict = {}                    # finance has not shipped the compact rate
broken = ParkingLot(
    levels=[Level(1, [Spot("L1-1", SpotKind.COMPACT)])],
    fit=SizeFit(),
    fees=fees_missing_a_row,
    now=lambda: 0,
)
t_lost = broken.park(Vehicle("DDD-444", VehicleSize.COMPACT))
assert t_lost is not None

try:
    broken.leave(t_lost.id)
    raise SystemExit("unreachable")
except KeyError:
    pass

assert t_lost.id in broken._open                 # the ticket was NOT consumed
assert t_lost.spot.vehicle is not None           # and the spot is still honestly occupied

fees_missing_a_row[VehicleSize.COMPACT] = HourlyFee(400, 250)    # finance ships the row
assert broken.leave(t_lost.id) == 400            # the same driver can now leave
assert broken.free_count(SpotKind.COMPACT) == 1
print("leave: a missing fee row is a retry, not a lost spot")

The setup is a lot whose fee table is empty; finance has not shipped the compact rate yet. A car parks fine, then hits KeyError on the way out.

The two asserts after the except are the point.

The first, t_lost.id in broken._open, deliberately reaches past the underscore into private state. That is normally bad practice, but here the thing under test is the internal state a failed exit is required to leave untouched, and there is no public way to observe it.

The second, t_lost.spot.vehicle is not None, says the spot is still honestly occupied.

Now imagine the pop-first version. The ticket is gone from _open while t_lost.spot.vehicle is still set, so the second assert still passes, for the worst possible reason: the spot is occupied by a car whose ticket no longer exists. The first assert is the one that catches it.

The recovery at the end of the block is the payoff. Finance ships the rate, leave is called again, and it returns 400. Under pop-first that same call raises KeyError: 'T1' instead, and will do so forever. A spot and a ticket are both lost, permanently, by one absent dictionary key.

The second test fails the design rather than defending it. It shows what happens when someone writes spot.vehicle directly instead of going through park.

t_over = lot.park(Vehicle("EEE-555", VehicleSize.COMPACT))
assert t_over is not None and t_over.spot.id == "L1-1"

t_over.spot.vehicle = Vehicle("FFF-666", VehicleSize.COMPACT)    # public field, no lock
assert t_over.vehicle.plate == "EEE-555"         # the ticket says one thing
assert t_over.spot.vehicle.plate == "FFF-666"    # the spot says another
lot.leave(t_over.id)
assert lot.free_count(SpotKind.COMPACT) == 1     # FFF-666 was freed out of existence
print("invariant: one vehicle per spot holds by convention, not by construction")

“One vehicle per spot” is the invariant this chapter names first, and nothing in the code enforces it.

Spot is a mutable dataclass with a public vehicle field, and Ticket hands the driver a direct reference to that spot. So t.spot.vehicle = other succeeds. No lock is involved, no exception is raised, and the previous occupant is gone from the garage’s records while still physically sitting in the bay.

What the lock from decision 3 removes is the interleaving: two threads inside park can no longer both see the spot free. It does not remove anyone’s ability to write the field, because a lock guards a section of code, not a piece of data.

So the honest statement is narrow: park is the only code that fills Spot.vehicle and leave the only code that clears it, by convention, not by construction. Nothing stops a third caller.

Making it true by construction is a small, specific change:

That is two more methods to write and two more call sites to change, and it converts a silent overwrite into a ValueError at the line that caused it. Whether it is worth it depends on who else will touch this code. The current design does not do it, and claiming otherwise in an interview is the kind of overstatement a follow-up question finds quickly.

The third state that is already here

One field in Spot has not been exercised yet: in_service. It is the reason is_free ANDs two booleans instead of testing one, and it is worth seeing work before extension 3 argues about it.

closed_lot = ParkingLot(
    levels=[Level(1, [Spot("L1-9", SpotKind.COMPACT, in_service=False)])],
    fit=SizeFit(),
    fees={VehicleSize.COMPACT: HourlyFee(400, 250)},
    now=lambda: 0,
)

assert closed_lot.free_count(SpotKind.COMPACT) == 0       # not free: it is closed
assert closed_lot.park(Vehicle("GGG-777", VehicleSize.COMPACT)) is None
print("in_service: a third state, bolted on as a second boolean")

The spot is empty and still refuses everyone, because is_free returns in_service and self.vehicle is None. Both readers of that property, Level.first_free and free_count, get the right answer without knowing the flag exists, which is encapsulation doing its job.

Notice what is missing. There is no close(spot_id) method, so in_service can only be set when the Spot is constructed. The attendant from the actors table has no way in.

And notice the shape. “Out of service” is a third answer to “what is this spot’s status”, added as a second independent boolean rather than folded into one model of availability. Two booleans span four combinations; three of them mean “not available” and only one means “available”. Add a fourth status and you are adding a third boolean and reasoning about eight combinations. That is the wall extension 3 hits, and in_service is the first crack in it.

The model is under a hundred lines and its four test blocks are about as long again. That is a complete answer. The parts an interviewer will point at are park (the critical section), SizeFit.ALLOWED (the policy table), and the injected now.

Extension 1 — now add EV charging bays

The first of three requirement changes is the one the design absorbs cleanly. The point is to see which earlier decision did the absorbing.

Ask one question first, because two different products hide here: may a non-electric car occupy an electric vehicle (EV) bay? Assume no, and assume EV bays bill for energy on top of time.

Here is the whole change, itemised. The rows worth studying are the three marked untouched; they are what “the design absorbed it” means.

WhatWhy
newSpotKind.EV, VehicleSize.EV_COMPACTTwo enum members
editOne row in SizeFit.ALLOWEDEV_COMPACT: {EV, COMPACT, LARGE}, and no other size lists EV
newEnergyFee, wrapping a FeeModelOne class, ten lines
editOne row in the fees dict handed to ParkingLotEV_COMPACT: EnergyFee(...). Forget it and every EV exit raises KeyError at the barrier
newA small Charger value objectWhatever the hardware needs: a connector type and a power rating
editSpot.charger: Optional[Charger]One optional field, 0..1 composition
untouchedParkingLot.parkIt asks the rule whether a spot fits; it has never known what a size means
untouchedLevel, Ticket, the lockNone of them branch on kind
untouchedEvery existing fee modelEnergyFee composes one instead of editing it

Only one of those rows is code worth writing out: the new fee model. Read it for the inner field: EnergyFee holds another fee model rather than replacing one.

from dataclasses import dataclass


@dataclass(frozen=True)
class EnergyFee:
    """Decorator: time is somebody else's problem, energy is mine."""
    inner: FeeModel
    cents_per_kwh: int
    kwh_per_hour: int

    def fee_cents(self, hours: int) -> int:
        return self.inner.fee_cents(hours) + hours * self.kwh_per_hour * self.cents_per_kwh


ev = EnergyFee(inner=HourlyFee(400, 250), cents_per_kwh=30, kwh_per_hour=7)
assert ev.fee_cents(3) == 1530                   # 900 + 3 x 7 x 30
print("EV: one new class, zero edits to the lot")

The word decorator in that docstring means the decorator pattern. It has nothing to do with Python’s @ syntax, despite the name collision.

The pattern is: an object that satisfies an interface, holds another object satisfying the same interface, and adds behaviour around it. EnergyFee is a FeeModel that holds a FeeModel. It delegates the time-based part to whatever it wraps, and adds the energy charge itself.

That is why no existing fee model needs editing. The new rule composes with them rather than replacing them.

Check the arithmetic against the assert. Three hours of time at the compact rate is 400 + 2 x 250 = 900 cents. The energy is 3 hours at 7 kilowatt-hours per hour at 30 cents per kilowatt-hour, which is 3 x 7 x 30 = 630. Total 900 + 630 = 1530.

This is a ten-line change because park never asks “what kind of vehicle is this”; it asks the rule, and the rule is data. The design absorbed the change because the branch it would have needed was already an object.

What it cost. Three things, none of them free.

Spot now has a nullable field, so anything that touches chargers needs a null check.

The fee for an EV bay is now assembled from two objects, so reading the total price means following a chain of wrappers. That is the readability tax decision 2 warned about, now charged twice.

The cheap-looking fees row is the sharp edge. A new VehicleSize and its fee entry are two edits in two files that nothing links together. The type checker is happy right up to the moment the first electric car tries to leave. That is the failure leave is ordered to survive, and the belt-and-braces version is a startup assertion that every VehicleSize member has a key in fees.

The assumption that paid off. Extension 1 is cheap because decision 1 assumed the fit policy would vary and kept it as data. Had spot kinds been subclasses, “add an EV bay” would have been a new class in a hierarchy plus a fit decision to place in every sibling; had the fit rule been the <= comparison, it would have been unexpressible, because EV-only is a restriction rather than a size.

Extension 2 — now price by time of day

This requirement change reveals a mistake, not in the class boundaries, which hold, but in a method signature. The difference between those two kinds of wrong is the finding here.

Peak hours cost more. Half of this change is nearly free, because of a decision made earlier: Ticket stores two timestamps rather than a duration.

The class below prices a stay hour by hour. Read the loop and the h % 24, which lets an ever-climbing hour counter map onto a repeating daily window.

from dataclasses import dataclass


@dataclass(frozen=True)
class TimeOfDayFee:
    """Bill each hour at the rate for the hour it started in."""
    peak_start: int
    peak_end: int
    peak_cents: int
    off_peak_cents: int

    def fee_for_window(self, entry_h: int, exit_h: int) -> int:
        total = 0
        for h in range(entry_h, max(exit_h, entry_h + 1)):
            in_peak = self.peak_start <= (h % 24) < self.peak_end
            total += self.peak_cents if in_peak else self.off_peak_cents
        return total


tod = TimeOfDayFee(peak_start=8, peak_end=18, peak_cents=500, off_peak_cents=200)
assert tod.fee_for_window(7, 10) == 1200         # 200 + 500 + 500
assert tod.fee_for_window(2, 5) == 600           # 3 x 200
print("time of day: possible only because the ticket kept both timestamps")

The loop bills one hour at a time. h % 24 is the hour of day (the remainder after dividing by 24), so an hour counter that keeps climbing past midnight still maps onto a daily peak window. Peak runs from 8 up to but not including 18.

Walk the two asserts:

max(exit_h, entry_h + 1) keeps the “any part of an hour is an hour” rule from the earlier fee model, so a zero-length stay still bills one hour.

Notice the method is called fee_for_window, not fee_cents. TimeOfDayFee is deliberately not a FeeModel. It cannot be, because the protocol says fee_cents(hours) and this rule needs two timestamps. That mismatch is the finding of this section.

The design absorbed the pricing rule itself because Ticket stores entry_h and exit_h rather than a duration. Had Ticket stored hours = 3, this extension would be impossible without a data migration (a batch rewrite of already-stored records), because the information needed to price a stay differently was thrown away at write time and cannot be recovered.

The general rule: store the observation, compute the rollup. A duration can always be recomputed from two timestamps; timestamps can never be recovered from a duration.

What it cost. The FeeModel protocol is now wrong. fee_cents(hours) cannot express time-of-day pricing, so the interface has to widen to fee_cents(entry_h, exit_h).

That widening is an edit to every existing implementation (HourlyFee, EnergyFee) and to ParkingLot.leave, which currently computes exit_h - t.entry_h before calling. Four files.

The signature was wrong. hours: int was a premature narrowing of the input. The fix is to widen the protocol to take the ticket’s window once, now, not to bolt a second method onto the interface and leave two ways to price a stay. The strategy boundary was right; the argument list was not.

The assumption, stated exactly. Decision 2 assumed how long fully determines price.

That is the same class of bet as decision 1’s “one vehicle size and one spot kind fully determine fit”, and it fails the same way: not by making the object wrong, but by making its inputs insufficient.

A strategy object protects you from changes to a rule, and gives no protection from changes to what the rule needs to know. When you extract a strategy, ask what else this rule might one day want to see. Passing the whole ticket costs nothing today and buys the entire extension.

Extension 3 — monthly passholders who reserve a spot

Monthly passes are the extension the design does not absorb. Working the failure honestly is worth more than a design that appears to survive everything, because the diagnosis generalises to every model you will build.

A passholder reserves L2-014 for weekday mornings. A reserved spot is not occupied, but it is also not available.

The design has no way to say that. Spot.is_free returns in_service and self.vehicle is None: two booleans, one of them a null check, None being Python’s spelling of “no value here”. A reservation is neither “somebody is parked here” nor “the spot is closed”, so neither flag fits.

The encapsulation is fine. The null check is written exactly once, inside Spot.is_free. Level.first_free and free_count never repeat it; they ask the property.

But the assumption still reaches all three, because what travels between them is a boolean: a yes-or-no with no room for a third answer. Hiding the check does not widen the answer.

Here is what the change costs. The FitRule row stays still; every other row moves:

FileChangeWhy
Spotvehicle: Optional and in_service: bool become state: SpotState plus holderFree / occupied / reserved / out-of-service is four states, not two booleans
Level.first_freetakes the driver, not just the size“Free for whom” is now a real question; it must pass a pass id down, not just read s.is_free
FitRuleunchangedIt only ever answered a geometry question
ParkingLot.parkmust consider a reservation before scanningAnd must release an expired hold
free_countcounts differently per callerThe board shows 40 free; a walk-up driver can use 12
new Reservationclass: holder, spot, window, stateThe verb that grew state, exactly as 02 predicted
new clock actorexpires unclaimed holdsOtherwise a no-show holds a spot forever

One new class and edits to four existing ones. That is a design that did not absorb the change.

The diagnosis: availability was modelled as the absence of a value instead of as an explicit state. A boolean cannot grow a third case. in_service was already the workaround for that, and it does not scale to a fourth.

The fix, applied retroactively

Two changes carry the repair, both visible in the class below.

First, state: SpotState replaces the booleans, so a spot has one named status instead of two independent flags.

Second, the part that transfers: is_free, a property with no arguments, becomes available_to(pass_id), a method that takes a subject.

from enum import Enum
from typing import Optional


class SpotState(Enum):
    FREE = "free"
    OCCUPIED = "occupied"
    RESERVED = "reserved"
    OUT_OF_SERVICE = "oos"


class Spot2:
    def __init__(self, id: str, kind: SpotKind) -> None:
        self.id = id
        self.kind = kind
        self.state = SpotState.FREE
        self.holder: Optional[str] = None       # pass id when RESERVED

    def reserve(self, pass_id: str) -> None:
        """State and holder move together, or neither moves."""
        if not pass_id:
            raise ValueError("a reservation needs a holder")
        self.state, self.holder = SpotState.RESERVED, pass_id

    def available_to(self, pass_id: Optional[str]) -> bool:
        if self.state is SpotState.FREE:
            return True
        return (self.state is SpotState.RESERVED
                and self.holder is not None            # an unheld reservation holds for nobody
                and self.holder == pass_id)


s = Spot2("L2-014", SpotKind.COMPACT)
assert s.available_to(None)                     # walk-up driver, spot is free
s.reserve("PASS-7")
assert not s.available_to(None)                 # walk-up is now correctly refused
assert s.available_to("PASS-7")                 # the holder still gets in

half = Spot2("L2-015", SpotKind.COMPACT)        # the field-write path, done wrong
half.state = SpotState.RESERVED                 # holder left at its default None
assert not half.available_to(None)              # a half-set reservation admits nobody
assert not half.available_to("PASS-7")          # not even a real passholder
try:
    half.reserve("")
    raise SystemExit("unreachable")
except ValueError:
    pass
print("reservations: availability is a question, not a field")

Walk the asserts first, then the design point.

A fresh Spot2 is FREE, so available_to(None), a walk-up driver with no pass, returns True. After reserve("PASS-7"), the same walk-up is refused and only "PASS-7" gets in. That is the three-line demonstration that availability now has a subject.

The shape of the repair is the part to remember. is_free, a property with no arguments, becomes available_to(pass_id), a question with a subject. Availability was never a property of the spot alone; it was always a relation between a spot and a driver. The original model hid that by having only one kind of driver.

The state enum then replaces vehicle is None with four named cases, so adding a fifth (“being cleaned”, say) is one enum member rather than a third boolean and a truth table.

The hazard the four-state model introduces

reserve() exists for a reason, because splitting one field into two creates a problem the boolean version did not have.

state and holder are now two fields carrying one fact, and two fields can disagree.

The second half of the code block shows the disagreement. Set state = RESERVED and forget holder, and holder keeps its default of None. Now consider available_to(None) for a walk-up driver: without a guard, self.holder == pass_id would be None == None, which is True. A reserved spot would be silently available to everybody, the exact opposite of what was asked for.

Two things close that hole:

Whenever you split one field into two, ask what their disagreement means, and make the disagreeing state either unrepresentable or harmless.

Why the day-one design was still right

No, you should not have built available_to() up front. With no reservations in the requirements you could not have named the second state, and 02 says do not build the abstraction you cannot name a second case for. The four-file edit is the price of that rule, and it is the right price to pay; the alternative is paying it on every design, including the eighty percent where reservations never arrive.

What encapsulation actually bought

The thing to defend having done on day one is encapsulation: keeping the free check behind one method instead of copying vehicle is None into every caller. That was already done. Spot.is_free is the only place the null test is written; Level.first_free and free_count ask the property.

Look at what it actually saved, because it is less than the slogan promises. The extension-3 table still lists four edited files, and Spot, Level.first_free, park and free_count are all on it. Encapsulating the check bought zero of those four.

What it bought is that each of the four is a cheaper edit.

Level.first_free does not have to find and rewrite a null test buried in a generator expression. It changes which question it asks (s.is_free becomes s.available_to(driver)) and its shape is otherwise untouched. Same for free_count.

The diff is a call-site substitution that grep and a type checker can both help you find, rather than a semantic rewrite in four independent places where the fourth one gets missed.

Encapsulation converts a scattered edit into a mechanical one. It does not convert an edit into no edit. Only an abstraction with the right shape does that, and this design did not have one, for the reason above.

The overclaim is common and easy to puncture: a candidate who says “I put it behind a method so the change was free” has not made the change.

The honest sentence is smaller: “I kept the check in one place, so the reservation change edited four files instead of rewriting a null test in four places, and one of those four rewrites is the one I would have missed.”

The assumption, and the general form of the lesson. This design assumed availability is binary, and encoded that assumption in the most brittle way available: as the absence of a value.

Null is a state with no name, so it can never be joined by a sibling. You cannot add a third case to None; you can only replace it. That is why in_service had to be a second field rather than a third case, and why a fourth status has nowhere to go.

Whenever you find yourself writing “free means this field is empty”, you have chosen a two-state model. That is the sentence you want on the record when the third state arrives.

The cheap insurance is not to build the enum early. It is to keep the question behind one method, which does not save the edit but makes every call site’s edit mechanical instead of archaeological.

What interviewers probe

Eleven questions do most of the asking on this problem. The table pairs each one with what it is measuring and the answer that measures well. Every question is a proxy for a habit, and answering the literal question without the habit reads as a guess.

They askThey are checkingStrong answer
“Why not subclass Spot per size?”Whether you inherit for data“The behaviour is identical; the difference is one field. Subclass when a method differs”
“Where does the fee logic go?”Single responsibility by actor“Its own strategy - finance changes it, operations changes the fit rule”
“Two cars, one spot?”Whether you can name the interleavingThe read-write window, then the lock around find-and-claim
“Is one lock enough?”Whether you size before you split“20 microseconds held, twice a second - 0.004% busy. Per-level locks buy nothing”
“What if there are two servers?”Whether you know the lock’s boundary“A process lock protects nothing across processes; the claim becomes a conditional UPDATE
“Now add EV bays”Whether the design absorbs itOne enum member, one table row, one decorator, zero edits to park
“Now price by time of day”Whether you kept the raw data“The ticket has both timestamps - and my fee_cents(hours) signature was too narrow”
“Now add monthly passes”Whether you can admit a break“Four files, because free was a null check. Here is the state enum that fixes it”
“What did your model assume?”Whether you designed or pattern-matched“One vehicle per spot, free means empty, one process. Here is what each would cost me”
“Where does persistence go?”Whether stubs were deliberateA repository behind the lot; the claim moves into the database
“How do you find my car?”Whether you scan“The ticket holds the spot, so it is O(1). Plate lookup would be a second index”

Cheat sheet

One row per decision: what was chosen, and the requirement change that justifies the choice. If you can reconstruct the third column from the second, you can redraw this design from memory.

ElementChoiceThe change that justifies it
Spot sizesEnum plus a fit tableMotorcycles banned from large spots: one set member
Vehicle typesOne frozen dataclass with a size fieldNothing about a truck behaves differently
Fit ruleStrategyThe policy is not a total order, so a comparison cannot express it
FeeA separate strategy, cents, per sizeFinance and operations change on different schedules
Fee inputThe ticket’s window, not a durationTime-of-day pricing needs the timestamps you nearly discarded
TicketClass with a lifecycle, holds a spot reference“Where did I park” is O(1)
AssignmentOne lot-level lock around find-and-claim1,000 x 20 ns held, 2 arrivals/s: 0.004% busy
Cross-processConditional UPDATE ... WHERE vehicle IS NULLA process lock protects one process
EV baysEnum member, table row, EnergyFee decoratorZero edits to ParkingLot
ReservationsSpotState enum plus available_to(holder)The one extension the design does not absorb
Display boardRead-only free_count, outside the coreBecomes an observer, a component notified on every change, only if live updates are asked for
Singleton lotNo. Construct one in main, inject it03

Four assumptions are worth naming unprompted, because naming them is what separates a design from a transcription:

  1. One vehicle per spot: held by convention, not by construction.
  2. Free means no vehicle is parked here and the spot is open: two booleans, no room for a third state.
  3. A stay is priced by its length: not by which hours it covered.
  4. All state lives in one process: so a threading.Lock is the whole concurrency story.

Each is defensible today. Each has a known replacement. Three of the four are why the extensions above cost what they cost: assumption 3 is what extension 2 breaks, assumption 2 is what extension 3 breaks, and assumption 4 is what a second server breaks.

Next: the worked problems that follow apply this shape to harder lifecycles: state machines, request queues, and booking systems where the race is over money rather than concrete. The method is unchanged: 02 for the six steps, 03 for the vocabulary.