The parking lot is the most-asked low-level design question, and most people answer it badly — not because the domain is hard, but because they start typing classes before deciding what the system is responsible for.
This is the module's capstone, and everything earlier appears in it: SOLID as the reason certain classes exist, the strategy pattern for the two rules that change most often, repositories for persistence, DTOs at the boundary, and the concurrency question that separates a diagram from a design. Work it in phases, out loud, exactly as below — the interviewer is grading your sequence as much as your classes.
Phase 1: Clarify the requirements
"Design a parking lot" is underspecified on purpose. Narrow it first.
Functional requirements — the small set that defines the system:
- A vehicle enters, is allocated a spot, and receives a ticket.
- A vehicle exits, the fee is computed from the duration, payment is taken, the spot is released.
- The lot has multiple floors and spots of different sizes.
- Staff can query available capacity per floor.
Explicitly out of scope, so you protect your time: reservations, monthly passes, licence-plate recognition, valet, an app, multi-site operation. The non-functional requirements shape the classes far more than the feature list does:
- Multiple entry and exit terminals operate simultaneously. The constraint that makes the problem interesting, and the one candidates skip.
- Pricing changes often — weekend rates, an EV surcharge, a first-fifteen-minutes-free promotion. It must change without touching entry or exit logic.
- A spot must never be allocated to two vehicles. Double-allocation is the one unacceptable failure.
- A single site of a few thousand spots. Not distributed, not internet scale. Say so, or you will design for a problem you do not have.
Phase 2: Entities and responsibilities
List the nouns, then immediately ask what each is responsible for. A class with no responsibility is a data bag pretending to be a design.
- ParkingLot — the aggregate root. Owns floors; the only entry point for park and unpark.
- ParkingFloor — owns its spots and reports its own availability.
- ParkingSpot — knows its type, its status, and which ticket currently holds it.
- Vehicle — a registration number and a size. Nothing more.
- Ticket — the record of an occupancy: spot, vehicle, entry time, exit time, fee.
- AllocationStrategy — chooses spots for a vehicle. PricingStrategy — turns a duration and a spot type into an amount.
- EntryTerminal / ExitTerminal — the boundary. Accepts input, returns a DTO, holds no rules.
- TicketRepository / SpotRepository — persistence, behind an interface.
Two decisions worth defending out loud. Do not subclass Vehicle by type — Car extends Vehicle and Truck extends Vehicle add a hierarchy carrying no behaviour, since every subclass differs only by a size constant. Inheritance is for differing behaviour; this is differing data, so model size as a value. And spot type and vehicle size are separate concepts that merely compare: a motorcycle fits a compact spot; a compact car does not fit a motorcycle spot. Encode that as an explicit fits rule rather than an integer comparison, because the relation is asymmetric and will grow exceptions — an EV bay takes any car, but only one that needs charging.
Phase 3: The class sketch
classDiagram
class ParkingLot {
+park(vehicle) Ticket
+leave(ticket) Fee
}
class Floor {
+allocate(vehicle) Claim
}
class Spot {
-size: Size
-state: State
}
class PricingStrategy {
<<interface>>
+price(ticket) Fee
}
ParkingLot --> Floor
Floor --> Spot
ParkingLot --> PricingStrategyCompact enough to read, complete enough to defend — interfaces first, because that is where the design lives.
const VehicleSize = { MOTORCYCLE: 1, COMPACT: 2, LARGE: 3 };
const SpotType = { MOTORCYCLE: 1, COMPACT: 2, LARGE: 3, ELECTRIC: 4 };
const SpotStatus = { FREE: 'free', OCCUPIED: 'occupied', OUT_OF_SERVICE: 'oos' };
class Vehicle { constructor(registration, size, needsCharging = false) {} }
class ParkingSpot { constructor(id, floorId, type) {} fits(vehicle) {} } // explicit rule
class Ticket { constructor(id, spotId, vehicle, entryTime) {} durationMinutes(at) {} }
// The two rules that change — strategies, registered in configuration
class AllocationStrategy { allocate(floors, vehicle) {} } // returns ranked candidates
class PricingStrategy { priceFor(ticket, exitTime) {} }
// SmallestFittingSpot | NearestToEntrance HourlyPricing | TieredPricing
// Ports
class SpotRepository { reserveFreeSpot(candidateIds, ticketId) {} release(spotId) {} }
class TicketRepository { save(ticket) {} findById(id) {} }
class PaymentProcessor { charge(amount, method) {} }
class ParkingLotService {
constructor({ floors, allocation, pricing, spots, tickets, payments }) {}
park(vehicleDto) {
const vehicle = Vehicle.from(vehicleDto);
const candidates = this.allocation.allocate(this.floors, vehicle);
const spotId = this.spots.reserveFreeSpot(candidates, ticketId); // atomic
if (!spotId) throw new LotFullError(vehicle.size);
return TicketDto.from(this.tickets.save(new Ticket(ticketId, spotId, vehicle, now())));
}
unpark(ticketId, paymentMethod) {} // price, charge, release, close
}The service takes its collaborators rather than constructing them — dependency injection doing its job, so the same ParkingLotService runs in tests against in-memory repositories and a fake payment processor with no conditional logic anywhere. And note that park returns a TicketDto, not the Ticket entity: the entity carries spot references, statuses and internal pricing state, none of which belongs on a terminal display or in an API response.
Phase 4: The behaviours that matter
Allocation. The strategy returns an ordered list of candidate spots, not a single choice. That small change is what makes the concurrency fix possible: if the first candidate is claimed by another terminal, the service tries the next without re-running the search. The two sensible orderings are smallest fitting spot (maximises capacity) and nearest to entrance (maximises satisfaction) — a business decision that changes seasonally, which is precisely why it is a strategy object and not an if.
Pricing. The fee depends on duration, spot type, and increasingly on rules nobody mentioned in the requirements. Behind PricingStrategy, a weekend rate is a new class registered in configuration, with no edit to unpark. That is Open/Closed paying for itself, and the clearest example of it in the design.
The flows. Entry: the terminal accepts registration and size, calls park, prints the ticket — and contains no rules whatsoever. If your EntryTerminal calculates anything you have broken single responsibility and made the logic untestable without a terminal. Exit: look up the ticket, price it at the current time, take payment, release the spot, close the ticket. The ordering there is a real decision — release the spot only after payment succeeds, or a declined card leaves a car occupying a spot the system believes is free.
Phase 5: Concurrency and edge cases
Here is the bug at the centre of this problem, and the reason two terminals were in the requirements. Two cars arrive at two entrances at the same instant. Both terminals ask for a spot, both allocation searches read the spot table, both see L2-047 as FREE, both return it, and both write a ticket. Two drivers are now directed to the same bay. This is a read-then-write race, and the fixes people reach for first do not work. A mutex in the service object holds only while there is one process — add a second terminal server, or run two instances behind a load balancer, and it protects nothing. Re-checking the status just before writing narrows the window without closing it, because there is still a gap between the check and the write.
The fix is to make the claim itself atomic — one operation that both tests and sets.
UPDATE spots
SET status = 'occupied', ticket_id = :ticketId
WHERE id = :spotId AND status = 'free'
RETURNING id;If the update affects zero rows, someone else won the race and the service moves to the next candidate — which is why the strategy returns a list. Optimistic locking with a version column is an equivalent answer.
Other edge cases worth raising before you are asked:
- Lost ticket. Charge a fixed maximum-duration penalty. This is why the spot, not the ticket, is authoritative about occupancy.
- Vehicle exits without paying. The ticket stays open; reconciliation flags spots occupied beyond a threshold.
- Spot out of service. A third status, not a deletion, so historical tickets still resolve.
- Clock and duration. Store timestamps in UTC, compute duration at exit, never store a running fee.
- Full for one size only.
LotFullErrormust carry the size — "no compact spots" and "lot full" are different messages to a driver.
Phase 6: Extending it without a rewrite
The test of the design is what a new requirement costs.
- Reservations — a fourth spot status plus a
Reservationentity; allocation skips reserved spots. No change to pricing or exit. - EV charging fee — a new
PricingStrategycomposed over the existing one. No change to allocation. - Monthly passes — a pricing strategy returning zero for a valid pass. No change anywhere else.
- A second site —
ParkingLotbecomes one of many; the service already takes floors as a dependency.
Each touches one class because the two things that genuinely vary — where a car goes and what it costs — were isolated behind interfaces from the start. That is the argument for the pattern, demonstrated rather than asserted.
Check your understanding
Common mistakes
Designing classes before clarifying scope. Reservations and passes are cheap to exclude and expensive to assume.
Subclassing Vehicle per type. A hierarchy where subclasses differ only by a constant is data modelled as inheritance.
Ignoring concurrency. Two terminals were in the requirements. A design that cannot say what happens when both claim one spot is incomplete.
Releasing the spot before payment clears. A declined card should not create a phantom free spot.
Practice
Sketch the classes for a multi-floor car park with motorcycle, car and van spots, where a van needs two adjacent car spots. State the responsibilities and where the awkwardness is.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
The van requirement breaks the obvious model. Find where before designing around it.
Show a strong answer
The straightforward part:
ParkingLot— owns floors, exposespark(vehicle)andleave(ticket). The only public entry point.Floor— owns its spots and knows what is free on it.Spot— an identity, a size, a state and its neighbours. Deliberately dumb.Vehicle— a size and a registration; a value, not a hierarchy of behaviour.Ticket— spot, entry time, vehicle. A record of a fact.PricingStrategy— the one interface, because pricing is stated to vary.
Where the awkwardness is: the van. Every other vehicle maps to one spot, so Spot.isFree() is a complete answer. A van needs two adjacent spots, which means:
- Free-ness is no longer a property of a single spot. The question becomes "is there a run of two adjacent free spots on this floor?", which
Spotcannot answer about itself. - The claim must cover both spots atomically, or a car takes the second one between the two writes and the van is half-parked.
- Adjacency has to be modelled explicitly, not inferred from spot numbers — numbering is rarely contiguous once you account for pillars and ramps.
How I would resolve it: make allocation a responsibility of Floor, not of Spot. Floor.allocate(vehicle) returns a claim over one or more spots, taken atomically, and knows its own layout. Spot stays dumb. This keeps the van case in one place instead of leaking if (vehicle is Van) into ParkingLot, Spot and the gate.
The general lesson: when one case breaks the model, move the decision up to the object that has enough context to make it — do not special-case it in every object that does not.
An interviewer asks 'how would you add electric-vehicle charging spots without rewriting this?'. Answer, and say honestly what you would have to change.
Write your own answer first, on paper or in a note. Then compare.
Show a hint
Do not claim nothing changes. Name what does and why that is acceptable.
Show a strong answer
What extends cleanly:
- Spot type. If size is already an attribute rather than a subclass per size, adding a charging attribute is data, not code.
Floor.allocategains a criterion rather than a branch. - Pricing. A charging rate is another
PricingStrategyimplementation — exactly the axis the design already anticipated.
What genuinely has to change, and I would say so rather than claim otherwise:
- The allocation criteria. An EV that needs charging must not take a charging spot when an ordinary one is free if chargers are scarce — and an EV that does not need charging should be allowed one if nothing else is available. That is a policy with real trade-offs, and it belongs in
Floor.allocateas a new rule. No amount of upfront abstraction writes that rule for you. - Something must model the charger itself — occupied, faulty, in use by a vehicle that has finished charging but not left. That is new state and a new failure mode, and it does not exist anywhere in the current design.
- Pricing may become time-based on two axes at once — parking duration and energy delivered — which may stretch a
PricingStrategyinterface built around duration alone.
The honest framing for an interviewer: the design absorbs the shape of the change — new spot attribute, new pricing strategy — because those were the axes stated to vary. It does not absorb the new domain concept of a charger with its own lifecycle, and no reasonable design would have. Claiming otherwise would mean having speculatively built a charger abstraction into a car park that had none, which is exactly the over-engineering the module warns against.
Key takeaways
- Clarify first: multiple terminals, changing prices and no double-allocation are the three requirements that shape every class.
- Give every entity a responsibility, and model vehicle type as data, not a subclass hierarchy.
- The two things that vary — allocation and pricing — belong behind strategy interfaces, which is Open/Closed made concrete.
- Inject repositories and the payment processor so the service is testable without infrastructure, and return DTOs across the boundary.
- The central hazard is a read-then-write race on a free spot; fix it with an atomic conditional claim, not a wider lock.
- Judge the design by extension cost: reservations, EV fees and passes should each touch one class.
Further reading
- Erich Gamma et al., Design Patterns (Addison-Wesley, 1994) — Strategy, for the pricing axis.
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) — aggregates, and why allocation belongs to the object that owns the invariant.
- Robert C. Martin, Agile Software Development: Principles, Patterns, and Practices (Prentice Hall, 2002) — the open–closed principle applied to extension points.
- Martin Fowler, Analysis Patterns (Addison-Wesley, 1996) — modelling physical resources and their occupancy.
- Bertrand Meyer, Object-Oriented Software Construction, second edition (Prentice Hall, 1997) — design by contract for the atomic claim.