Design a Parking Lot (LLD)
Model entities, relationships, and pricing so new vehicle types or rules never force a rewrite.
- 1,000 spotsfive floors of 200 spots each
- ~500 / hourentries at peak, under one per second
- O(types)allocation cost with free sets per type
Show what to say
“I'd model ParkingLot, Floor and Spot, and keep a free set per spot type on each floor, so allocating a spot never scans. Spot choice and pricing sit behind small strategies, with a daily cap as a decorator. One Node process allocates atomically; across gate servers the database picks and locks a row with SKIP LOCKED.”
Trade-offs
- Free sets per type vs a scan: allocation is O(types) and pays off past hundreds of spots, but there is more state to keep consistent.
- Strategy classes vs if/else: rules are swappable and testable, and rules do change, but you get more files.
- In-memory vs DB as source of truth: simple and fast for one gate controller, but it only works in one process.
Bring it up before they ask
Two gates must never hand out the same spot: an atomic take() in one process, SKIP LOCKED across servers, with the ticket inserted in the same transaction so a crash cannot leak a spot.
Model ParkingLot → Floor → Spot, put spot choice and pricing behind small swappable rules, and keep a free list per spot type so nothing scans. Across gate servers, the database locks the spot with FOR UPDATE SKIP LOCKED.
Functional
- Bike, compact and large spots on several floors
- A ticket at entry, the fee at exit
- Free spots per floor and type on display boards
- Several payment methods and gates
Non-functional
- Never give a spot out twice, even with concurrent gates
- No full scan to allocate
- New vehicle types or pricing rules: no edits to core classes
- Deterministic tests (injected clock)
Out of scope
- Reservations (a follow-up)
- Plate-recognition hardware
- A chain of lots (an HLD follow-up)
A low-level design (LLD) question: pick the classes first, then find where two gates collide.
New to this? Read From requirements to classes, SOLID and Swap behaviour first.
Scope, scale and interface
Ask: how many floors and spots?
Read more · 148 words
Which vehicles fit which spots? How is the fee worked out? Where do drivers pay? One lot, or a chain?
Five floors of 200 spots is 1,000 spots and about 500 cars an hour. One small process can run it. The hard part is two gates never giving out the same spot.
A scan of 1,000 spots takes microseconds, so speed alone does not justify free sets. They keep the smallest-first rule simple and the cost flat as lots grow past hundreds of spots.
The interface:
| Call | Returns | Errors |
|---|---|---|
park(vehicle) | Ticket with id, spot and entry time | Lot full for car |
quote(ticketId) | Fee owed right now | Unknown ticket |
exit(ticketId, payment) | { fee, change }, and the spot is freed | Payment of 40 required, Unknown ticket |
floor.available(type) | Free spots of that type on the floor | |
event changed(floor) | Fired after every park and exit |
Build the design in stages
Build it like a whiteboard sketch: nouns first, then move each rule that will change behind an interface.
Read more · 111 words
Green marks what the latest stage added.
Not every rule needs a class. Do the sum on pricing, at 20 an hour for a car:
| Rules today | Code |
|---|---|
| Hourly only | One function |
| Plus a 150 daily cap | Borderline, one if reads fine |
| Plus weekend rates, EV charging, monthly changes | Strategies, cap as a decorator |
A car parked 8 hours owes 160 hourly, so the cap bites. The second rule is the tipping point. Until it exists, a function is enough.
What it remembers:
ParkingLot.floors: floors by level.Floor.free: spot type → set of free spots.ParkingLot.tickets: open tickets by id. Exit deletes the entry, so a ticket works once.
ParkingLot owns its floors and open tickets, and floors own their spots. Spot choice, pricing and payment sit behind small interfaces, so new rules are new classes, not edits. Card, Cash and Wallet implement PaymentMethod.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
Watch it run
Park a few cars, then click one to pay.
Read more · 35 words
Try Two cars, one spot on each gate setting: an atomic take() turns the second car away, but check-then-take sends both to 0-C3.
Then play the sequence, including the lot full and payment failed branches.
- Client
- Service
- Cache
- External API
Text description
- Gate → ParkingLot: park({ type: 'car' })
- ParkingLot → Selector: choose(floors, car)
- Selector → Floor: take([compact, large])
- Floor replies to Selector: spot 0-C1
- Selector replies to ParkingLot: spot 0-C1
- ParkingLot: tickets.set(id, ticket)
- ParkingLot replies to Gate: ticket (id, spot, entry)
- Gate → ParkingLot: quote(ticketId)
- ParkingLot → Pricing: fee(61 min, car)
- Pricing replies to ParkingLot: 40 (2 started hours)
- ParkingLot replies to Gate: fee: 40
- Gate → Payment: pay(40)
- Payment replies to Gate: paid: amount 40
- Gate → ParkingLot: exit(ticketId, payment)
- ParkingLot: tickets.delete(id)
- ParkingLot → Floor: release(spot 0-C1)
- ParkingLot replies to Gate: fee 40, change 0
Step-by-step answer
Use cases first
Park, pay, exit, show free spots.
Nouns become classes, verbs become methods
Spot,VehicleandTickethold data.ParkingLot,Floorand the rules hold behaviour.Relationships
The lot owns floors, floors own spots. A ticket only points at a spot.
Allocation without a scan
FITSlists the spots each vehicle may use, smallest first:export const FITS = { bike: ['bike', 'compact', 'large'], car: ['compact', 'large'], truck: ['large'], };take(types, accept)pops the first free spot that passesaccept.Pricing as a strategy
fee(durationMs, vehicle)is the whole interface. A daily cap wraps it as a decorator. Pass in the clock.Payment flow
Quote, pay, then
exitre-checks the amount and frees the spot. A failed payment changes nothing.Concurrency
One Node process is atomic for free. Several servers need one statement that picks and locks a row:
UPDATE spots SET ticket_id = $1, taken_at = now() WHERE id = (SELECT id FROM spots WHERE ... AND ticket_id IS NULL LIMIT 1 FOR UPDATE SKIP LOCKED) RETURNING id, floor;Insert the ticket in the same , or a crash loses the spot.
Display boards
The lot announces
changed(floor). Boards listen (an observer).Extensibility check
A new vehicle is one
FITSrow.Tests
Lot full, bad ticket, grace period, daily cap, all with a fake clock.
Implementation
The same design in JavaScript, Java or Python. Java and Python add real locks.
Read more · 47 words
HourlyPricingcharges every started hour: 61 minutes is 2 hours.DailyCap: 26 hours for a car is 150 + 40 = 190.- Pick one floor-versus-type policy and use it in both the code and
allocate.sql.
Try the Parking Lot exercise: hidden tests, and both rules switch at runtime.
export const VehicleType = Object.freeze({ BIKE: 'bike', CAR: 'car', TRUCK: 'truck' });
export const SpotType = Object.freeze({ BIKE: 'bike', COMPACT: 'compact', LARGE: 'large' });
// Which spot types each vehicle may use, smallest first, so a bike never takes the large spot a truck needs
export const FITS = {
[VehicleType.BIKE]: [SpotType.BIKE, SpotType.COMPACT, SpotType.LARGE],
[VehicleType.CAR]: [SpotType.COMPACT, SpotType.LARGE],
[VehicleType.TRUCK]: [SpotType.LARGE],
};
export class Spot {
constructor(id, type, floor) { Object.assign(this, { id, type, floor, features: new Set() }); } // features: 'ev', 'accessible'…
}Persistence across gate servers
Only if asked. The database is the truth: a spot is free while ticket_id is NULL.
CREATE TABLE spots (
id text PRIMARY KEY, -- '2-C14'
lot_id bigint NOT NULL,
floor int NOT NULL,
type text NOT NULL CHECK (type IN ('bike', 'compact', 'large')),
features text[] NOT NULL DEFAULT '{}', -- 'ev', 'accessible'
ticket_id uuid UNIQUE, -- NULL means free
taken_at timestamptz
);
CREATE INDEX spots_free ON spots (lot_id, type, floor, id) WHERE ticket_id IS NULL;
CREATE TABLE tickets (
id uuid PRIMARY KEY,
lot_id bigint NOT NULL,
plate text NOT NULL, -- lets you find a lost ticket
vehicle text NOT NULL,
spot_id text NOT NULL REFERENCES spots (id),
entry_at timestamptz NOT NULL DEFAULT now(),
exit_at timestamptz,
fee int
);Failure modes and trade-offs
Read more · 159 words
| What breaks | Effect | Mitigation |
|---|---|---|
| Two gates allocate at once | Same spot twice | One process: atomic take(). Several: SKIP LOCKED |
| Gate crashes before the ticket prints | Spot lost | Spot and ticket in one transaction; a cleanup job frees orphans |
| Lost ticket | No fee | Find it by plate, or charge the daily maximum |
| Gate loses its network | No central DB | A local service works offline and syncs later |
| Payment declined | Driver stuck | Nothing changes until payment works; offer another method |
Choices to defend:
| Choice | Pros | Cons |
|---|---|---|
| Free lists vs a scan | Fast, even with many spots | More state to keep right |
Strategy classes vs if/else | Rules swap and test easily | More files |
| Memory vs database | Simple and fast | One process only |
| Decorators vs one class | Rules combine freely | Wrapping order matters |
Follow-up questions
How would you scale to 500 lots?
Run a local service per lot, offline-safe, syncing to a central one split by lot_id.
How do you handle a lost ticket?
Find the open ticket by plate and charge from the real entry time. No match: charge the daily maximum.
How would you add reservations?
Reserve a spot type for a time window, not one spot. Release no-shows after a grace period.
How would you add EV charging?
Add an ev spot feature, a needsCharger filter and an EnergyFee decorator. No existing class changes.
Is take() really atomic in Node?
In one process, yes. Across processes, allocate in the database (SKIP LOCKED) or (SET NX).
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Patterns behind this
The coding patterns this design is built on. Practise them and the design questions get easier.