A parking lot system runs the machinery of a garage. When a car rolls up to the entry gate it issues a ticket, while the driver is away it remembers which spot the car occupies, when the driver returns it computes a fee, and once the fee is settled it opens the exit barrier. Garage operators need the software because a thousand-spot structure cannot be managed with a clipboard, and drivers need it because circling a full level hunting for a space is the worst part of parking. Interviewers ask the question for a different reason than they ask the distributed systems questions. It is the classic object-oriented design warm-up, and it shows whether a candidate can carve a messy physical domain into classes with clean boundaries before any talk of servers begins.
The hard parts hide in unglamorous places. The class model has to survive requirement changes like a new vehicle type or a new pricing scheme without a rewrite, two cars can race for the last free spot and both must not win, and the pricing rules carry edge cases such as lost tickets, grace periods, and overnight stays that break naive implementations. None of those difficulties appears in the happy path, which is why the question rewards candidates who narrate failure cases without being prompted. The last section then does what interviewers often do in the final ten minutes, which is to scale the single garage into a city-wide operator with real-time availability, a twist that turns the object design exercise into a small distributed systems question.
Scope and requirements
The opening minutes belong to requirements gathering, because a parking lot can mean anything from a flat lot with an honor box to a ten-level structure with license plate cameras, and designing the wrong one wastes the whole interview. I would confirm a structure with multiple levels, each holding a mix of spot sizes, and three vehicle types to start, namely motorcycles, cars, and buses. A motorcycle fits in any spot, a car needs a compact spot or larger, and a bus needs either a row of adjacent large spots or a dedicated oversized spot. Between those bus options I would choose the dedicated oversized spot, because the row variant forces a claim to take several adjacent spots atomically, which complicates the concurrency story for a vehicle type that might be two percent of traffic, and I would flag the row variant as an extension rather than build it now. Entry and exit happen at gates, a ticket is issued on entry, and the fee depends on duration and vehicle type. Payment can happen at a kiosk inside the garage or at the exit gate itself, which means a paid ticket must remain valid for a short exit window, typically ten or fifteen minutes, so a driver who pays and then waits for an elevator is not charged again at the barrier. Monthly passes, valet service, and electric vehicle charging are real features in production garages, and naming them as out of scope lets the interviewer pull one back in if it matters to them.
The non-functional requirements are modest in throughput and strict in correctness. A gate decision must feel instant to a driver, so spot assignment should complete in well under a second, and in practice the software portion should take a few milliseconds so that the physical barrier dominates the wait. The system must never assign one spot to two vehicles, because the failure mode is two cars nose to nose in a lane with a queue building behind them and a human attendant untangling it. It must not lose tickets while cars are inside, since a power cut that wiped open tickets would strand a full garage with no record of who owes what, and it should keep working at the gates even if some central service is unreachable. For the city-scale extension, availability counts shown to drivers may lag reality by a few seconds, and stating that relaxation early makes the later design much easier, because it separates the one operation that needs an authoritative answer from the many that do not.
Sizing the problem
Back-of-the-envelope numbers show why this question is about correctness rather than throughput. Take a garage with 4 levels of 250 spots, so 1,000 spots total. If the morning rush fills the whole structure in two hours, that is 1,000 entries in 7,200 seconds, or one car roughly every 7 seconds across the building, and a garage with four entry lanes therefore sees a car per lane about every 28 seconds. The evening exit is usually more compressed because office workers leave in a narrower band, so doubling the rate to an event every 3 or 4 seconds is a fair stress case, and even that amounts to well under one request per second. Any single machine handles this without noticing. The reason to be careful with concurrency is not load but the fact that two gates can ask for the last compact spot in the same instant, and a correctness failure at one request per second is still a correctness failure.
Storage is just as small, and walking the multiplication makes that concrete. A ticket record holds a few IDs and timestamps, comfortably 200 bytes, and a busy garage turning over each spot twice a day issues about 2,000 tickets daily. Two thousand tickets at 200 bytes is 400 KB per day, which compounds to around 150 MB per year, so ten years of complete history for one garage fits in 1.5 GB and never justifies deleting anything. Even the city operator with 200 garages produces only about 400,000 tickets and a little over a million gate events per day, and a million events spread across 86,400 seconds averages out near 14 events per second, with rush-hour peaks perhaps five times that. These numbers free the design to optimize for clarity and safety instead of scale, and saying so out loud is itself a design decision, because reaching for sharded queues here would solve a problem the arithmetic says does not exist.
The object model
The core classes fall out of the physical nouns, but the boundaries between them are where the design judgment lives. A ParkingLot owns a list of Level objects and holds the pricing policy; a Level owns its ParkingSpot objects and, critically, owns the bookkeeping of which spots are free; a ParkingSpot knows its size and its current occupant; and a small Vehicle hierarchy with Motorcycle, Car, and Bus subclasses exists mostly to answer one question, namely what spot size the vehicle needs. A Ticket records the spot, the vehicle, and the entry time, and accumulates payment state over its life. The classes themselves are deliberately plain, because in a domain model the judgment shows in where responsibilities sit, and the most consequential placement here is that the level, not the lot and not the spot, owns the knowledge of what is free, since that is the unit at which claims will later need to be made safe.
Two choices deserve explanation out loud. Inheritance is kept to the one place it earns its keep, which is the vehicle hierarchy, and even there the case is marginal. That hierarchy is shallow and stable, since new vehicle types arrive rarely and differ only in a size attribute and perhaps a pricing category, and a reasonable design even replaces the subclasses with a single Vehicle class carrying a type enum, trading a little polymorphism for one lookup table. I considered the same treatment for spots, with MotorcycleSpot, CompactSpot, and LargeSpot subclasses, and rejected it because the spots differ only in data and never in behavior, and a subclass that adds no methods is just a size field wearing a costume. Everywhere else composition wins, where composition means building behavior by holding other objects as parts instead of inheriting from them, so a level has spots rather than being some kind of spot container subclass, the lot has levels, and each class stays replaceable on its own.
The second choice puts pricing behind a strategy interface. The strategy pattern means defining a family of interchangeable algorithms behind one interface so the caller never knows which concrete rule is running. A FeeStrategy exposing quote(ticket) → Money can be implemented by an hourly rate, an hourly rate with a daily maximum, a flat event rate for stadium nights, or a per-vehicle-type table, and the operator can swap policies per garage or per day without touching the ticket or gate code. The alternative is the one most first drafts contain, a computeFee method on Ticket with a growing chain of conditionals, and it loses because every new policy edits the same shared method, the ticket class slowly absorbs knowledge about stadium events and early-bird discounts that is none of its business, and per-garage variation forces configuration flags into a class that should stay a plain record of facts. Pricing is the requirement most likely to change in this domain, so it gets the most protective boundary.
Composition runs left to right, with the lot owning levels and a pricing policy, each level owning its spots and free lists, and a spot holding at most one vehicle. Tickets reference the spot and the vehicle, while pricing hides behind the FeeStrategy interface.
Spot assignment and the race for the last spot
Assignment policy comes first, mechanics second. Each level keeps a free list per spot size, which is simply a queue of currently empty spots of that size, ordered so that the head is the spot nearest the elevators or the entrance. When a car arrives, the lot asks levels in order of preference, ground level first, and each level checks the smallest spot size that fits the vehicle before trying larger ones, so a motorcycle takes a motorcycle spot if one exists and only borrows a compact spot when it must. Smallest-fit matters because the scarce resource is the large spot, and a policy that happily parks cars in oversized spots all morning is the policy that turns a bus away at noon. Nearest-first ordering matters because drivers judge a garage by the walk to the elevator, and pre-sorting the queue encodes that preference once instead of recomputing it per arrival. The alternative worth naming is a best-fit scan over the raw spot array on every request, and it loses on both axes, since a scan over 250 spots is slower than a constant-time pop and it re-derives an ordering the queue already holds.
The race is the genuinely hard part. Two entry gates can request a spot in the same millisecond, and if both threads read the same free list, both can see the same last spot and both can return it. That situation is a race condition, meaning an outcome that depends on the accidental interleaving of concurrent operations, and it will happen rarely enough to pass every casual test and often enough to embarrass the garage monthly. The fix at single-garage scale is a lock, which is a mutual exclusion primitive guaranteeing that only one thread runs a protected block at a time. Locking per level rather than per lot keeps gates on different levels from queuing behind each other, while keeping the critical section tiny, just a queue pop and a state flip before the release. A single lot-wide lock would also be correct and is easier to reason about, but it serializes every gate in the building through one queue for no benefit, and going finer than a level, say a lock per spot, makes the nearest-first ordering awkward to maintain because the queue itself still needs protection. Per level sits in the comfortable middle.
class Level:
def __init__(self, number, spots):
self.number = number
self._free = build_free_lists(spots) # SpotSize -> deque, nearest first
self._lock = threading.Lock()
def claim_spot(self, vehicle_size):
with self._lock: # one gate at a time, per level
for size in FITS[vehicle_size]: # smallest acceptable size first
if self._free[size]:
spot = self._free[size].popleft()
spot.hold()
return spot
return None # caller tries the next level
The same idea survives the move from in-memory objects to a database, where the lock becomes an atomic claim. A compare-and-set is an operation that changes a value only if it still holds the expected old value, and in SQL it looks like UPDATE spots SET state = 'HELD' WHERE spot_id = ? AND state = 'FREE', where a result of zero rows updated means another gate won and the caller picks a different candidate. Saying both versions in the interview makes the point that the concept matters more than the mechanism, because there must be exactly one authoritative claim step and everything else can be relaxed around it. One leak in this scheme is worth naming unprompted. If a gate controller claims a spot and then crashes before issuing the ticket, the spot sits held with no car in it, so a held spot carries a short expiry of a minute or two after which a background sweeper returns it to the free list, and the same expiry machinery later does double duty for reservations at city scale.
A latency budget at the gate
Walking the entry path with a stopwatch shows where time can and cannot be spent. A driver pulls up and trips an induction loop or a camera, and the physical world takes a few hundred milliseconds before the software even hears about it. The gate controller then calls the garage server over the local network, where the round trip costs a millisecond or two, the per-level lock acquisition costs microseconds when uncontended, the free-list pop is constant time, and the ticket insert into the local database takes a few milliseconds with the disk commit dominating. Printing or displaying the ticket consumes about a second of hardware time, and the barrier arm needs two or three seconds to rise. Adding it up, the software contributes perhaps 10 milliseconds to an interaction the driver experiences as four or five seconds, which says the claim path has two orders of magnitude of headroom and that optimizing it further would spend effort where no human could ever feel the difference.
The budget also clarifies what degradation should look like. When the garage server takes 500 milliseconds because its standby is syncing, no driver notices anything. When it is unreachable for ten seconds, the gate must not hold the line of cars, so the controller falls back to issuing tickets from its local cache and reconciling afterward, accepting a small risk of briefly overcommitting a nearly full level in exchange for never blocking the lane. A queue at a gate backs up into the street within a minute, and the city does not care that the cause was a database failover.
The ticket lifecycle
A ticket is born at the entry gate, where the gate controller requests a spot, the system claims one, and the printed or virtual ticket records the ticket ID, spot, and entry time. The driver parks, returns hours later, and pays at a kiosk or at the exit gate, at which point the fee strategy quotes a price from the elapsed time and vehicle type and the ticket is marked paid with a timestamp. At the exit gate the system validates that the ticket is paid and within the exit window, opens the barrier, releases the spot back onto its level's free list, and closes the ticket. Persisting each transition matters because the garage must recover its exact occupancy after a crash. The ticket table is therefore the source of truth while the in-memory free lists are rebuildable state, and recovery after a restart means scanning the open tickets, marking their spots occupied, and deriving every free list from what remains, a procedure that takes well under a second for a thousand spots and means a power cut costs the garage nothing beyond the outage itself. Keeping the authoritative state in one table rather than spread across spot rows and counters also makes disputes tractable, since an operator answering a driver complaint reads one row and sees the entire story of the visit.
CREATE TABLE tickets (
ticket_id UUID PRIMARY KEY,
garage_id TEXT NOT NULL,
spot_id TEXT NOT NULL,
vehicle_type TEXT NOT NULL,
plate TEXT,
entered_at TIMESTAMPTZ NOT NULL,
paid_at TIMESTAMPTZ,
amount_cents INTEGER,
exited_at TIMESTAMPTZ
);
A car triggers the entry gate (1) and the ticket service atomically claims a free spot (2), after which the ticket is issued and the gate opens (3). The driver later pays at the kiosk where the fee strategy quotes the price (4) and the ticket is marked paid (5). At departure the exit gate validates the paid ticket (6), and the spot returns to the free list as the barrier lifts (7).
The pricing edge cases are where production systems differ from whiteboard answers. A grace period, typically 10 to 15 minutes, lets a driver who entered by mistake or found no acceptable space leave free, and it is cleanest to implement inside the fee strategy as a zero quote rather than as gate logic, because gate code that knows about one pricing exception will inevitably grow more of them. A lost ticket cannot become a free exit, so the policy is a fixed worst-case charge, usually the daily maximum, plus a plate lookup where cameras exist so the real entry time can be recovered, and the kiosk should explain the charge plainly rather than fail, since the driver at that screen is already having a bad day. Overnight and multi-day stays argue for charging in capped day units rather than raw hours, which again is a strategy implementation detail, and the day boundary should follow the garage's local clock rather than UTC so a Friday evening to Saturday morning stay bills as two capped days, not one long hourly run. Each of these rules lives inside a fee strategy, which is the quiet payoff of the earlier boundary, because none of them required touching the gate code, the ticket class, or each other.
From one garage to two hundred
The natural follow-up scales the design to a city operator with 200 garages and an app that shows live availability and lets a driver reserve a spot. The right structure keeps each garage autonomous, so gates, ticketing, and spot claims stay local and a fiber cut never strands cars, while every gate event also flows asynchronously to a central garage service that maintains per-garage availability counters and serves the app. Pushing events from the garages beats having the center poll them, because events carry exactly the deltas the counters need, arrive within a second of the physical action, and a garage that goes silent becomes its own alarm signal. The arithmetic from the sizing section says the whole city produces a few dozen events per second, so the central service stays small, and the interesting property is consistency rather than throughput. The tempting alternative of moving spot claims to the center, so the app and the gates share one inventory, loses outright on the autonomy requirement, since it would put every gate decision in the city behind a wide-area network hop and make a backhoe somewhere downtown capable of closing every garage at once.
Availability displays tolerate eventual consistency, which means readers may briefly see slightly stale values that converge once updates stop arriving. A driver ten minutes away does not act differently because a sign reads 31 free instead of the true 29, and by the time anyone arrives the count has changed anyway, so the app can serve cached counters updated from the event stream with a few seconds of lag. Reservations are the one place that needs a real claim. When the app requests one, the central service forwards an atomic hold to the target garage exactly like the hold a gate would make, and the hold expires after a fixed window of perhaps 30 minutes so abandoned reservations cannot bleed capacity. If the garage happens to be unreachable at that moment, the reservation request simply fails and the app says so, which respects the principle that each garage is the sole authority over its own spots, and the disappointed driver can still arrive as a walk-up. Operators usually also cap reservable spots at a small fraction of each garage, because every reserved spot is one a walk-in cannot take, and a half-empty reserved row beside a full sign is the kind of sight that loses regular customers.
GET /api/v1/garages?lat=37.78&lng=-122.41&radius_km=2
→ 200 [ { "garage_id": "g42", "free": { "compact": 31, "large": 4 },
"as_of": "2025-01-18T09:14:02Z" } ]
POST /api/v1/garages/g42/events // from gate controllers
{ "gate_id": "entry-2", "type": "ENTRY", "ticket_id": "t-9f3",
"vehicle_type": "CAR", "ts": "2025-01-18T09:14:01Z" }
POST /api/v1/reservations
{ "garage_id": "g42", "spot_size": "COMPACT", "plate": "8ABC123" }
→ 201 { "reservation_id": "r-77", "expires_at": "2025-01-18T09:45:00Z" }
Scaling, failures, and operations
Each tier fails in its own way, and the design holds up because authority sits in the right places. The gate controller is the component that must keep working alone, so it caches the pricing table and enough state to issue tickets and accept payment during a network outage, queuing events locally and replaying them to the garage server when connectivity returns, with the worst symptom being a stale availability sign. A driver passing through during such an outage notices nothing, which is exactly the goal. The garage server is the authority for spots and tickets within its building, so it runs with a local database and a warm standby, and recovery means replaying the ticket table to rebuild free lists in the way the lifecycle section described. The central service is read-mostly and stateless in front of a counter store, so it scales horizontally, a crash there costs only the app experience and never a parked car, and the operator who gets paged for it can treat it as a daytime problem rather than a midnight emergency.
The operational tails are physical as much as digital. Sensors miscount, drivers straddle two spots, and a car can follow another through a broken barrier without producing a closing event, so occupancy drifts away from the database no matter how correct the software is. Drift calls for reconciliation, meaning a nightly sweep that compares tickets open in the database against camera or sensor reality and corrects the counters, and the availability number shown publicly should be conservative by a small buffer so the sign never promises a spot the garage cannot produce. Payment provider outages argue for letting cars out on a recorded promise-to-pay with plate capture rather than trapping them, since holding a garage full of angry drivers costs more in goodwill and lane blockage than the occasional unpaid fee ever could. None of this changes the object model, which is the sign the original boundaries were drawn well.
Follow-up questions
- Why a strategy interface for pricing instead of methods on Ticket? Pricing is the most volatile requirement in the domain, with per-garage, per-day, and per-vehicle variants arriving steadily from the business side, and isolating it behind
quote(ticket)lets policies change without touching ticket or gate code. The ticket stays a plain record of facts, which also keeps it easy to audit when a driver disputes a charge months later. - How do you prevent two cars from winning the last spot? Every claim funnels through one atomic step, which is a per-level lock around the free-list pop in memory or a conditional update like
UPDATE ... WHERE state = 'FREE'in a database. Zero affected rows means the other gate won, and the losing caller quietly moves on to the next candidate spot rather than surfacing an error. - What does a lost ticket cost? The daily maximum by policy, since anything cheaper teaches long-stay drivers that losing the ticket is a discount, softened by plate lookup where cameras can recover the true entry time and bill fairly. The fee strategy owns this rule like any other, so introducing it touched no gate code.
- Why is eventual consistency acceptable for availability displays? The count is advisory rather than contractual, because a driver minutes away cannot act on the difference between 31 and 29 free, and the value will have changed before arrival regardless. Only reservations need an authoritative claim, and those go through the garage's atomic path so the one promise the app makes is one it can keep.
- Why per-level locking instead of one lot-wide lock? Correctness allows either, but a single lock serializes every gate in the building through one queue, while per-level locks let independent gates proceed in parallel and keep each critical section to a queue pop. The contention difference is invisible at this load, yet the habit of scoping locks to the data they protect is what the question is really probing.
- What changes first at 10x scale, meaning 2,000 garages? Almost nothing structural changes, because garages stay autonomous, the central service shards counters by garage ID, and event volume of a few hundred per second remains small for any stream processor. The harder growth problem is operational rather than architectural, namely reconciling sensor drift and broken hardware across thousands of buildings with a finite maintenance crew.
References
- Gamma, Helm, Johnson, Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (1994), on strategy and composition over inheritance.
- Goetz et al., Java Concurrency in Practice (2006), on locks, atomicity, and critical sections.
- Kleppmann, Designing Data-Intensive Applications (2017), on consistency models for the city-scale extension.
- Xu, System Design Interview, Volume 1 (2020), on requirement scoping and estimation habits.