Design Patterns & LLD

Design a Parking Lot

Best-fit and nearest-spot strategies, hourly pricing with a daily cap, and a double-booking race fixed with compare-and-set.

Lesson 0528 min read#parking-lot#strategy#concurrency

On this page

  1. Words used in this lesson
  2. Step 1. Ask before you draw
  3. Step 2. Nouns become classes, verbs become methods
  4. Step 3. The full solution
  5. Slot allocation strategies, compared
  6. Pricing strategies
  7. Concurrency: the double-booking note
  8. Testing it
  9. Extension questions you should expect
  10. Interview questions
  11. Key takeaways
  12. What's next

In one line: a parking lot is the classic first low-level design question. The nouns (vehicle, spot, level, ticket) become classes, but the marks go to two decisions you keep swappable: which spot do we give (an allocation strategy) and how much do we charge (a pricing strategy), plus one honest sentence about two cars trying to take the same spot at the same time.

Low-level design (LLD, also called object-oriented design or OOD) means: given a small problem, design the classes, their fields and methods, and how they talk to each other, in code. It is not about servers and databases. This lesson walks through one full question in the order I would use in an interview: ask, list the nouns, draw the classes, write the code, test it, then handle the follow-ups.

Read Lesson 0 first for how an LLD interview is run and for the SOLID rules. This lesson uses the strategy pattern from Lesson 3 and the decorator pattern from Lesson 2. If you want to see the same idea of "who owns the spot" at the database level, the distributed versions are in the system design series.

What I ran / checked. Every code block below is a real file that I ran on Node 22.22 with its built-in TypeScript support (no compiler step). That mode only removes types, so it does not allow enums or constructor parameter properties (constructor(private x: number)). That is why the classes write their fields out. All tests use node:test. The question and its starting assumptions come from the parking lot notebook in donnemartin/system-design-primer (solutions/object_oriented_design/parking_lot), which I read directly. That notebook is a sketch: some of its methods are only # ..., and a few lines would not run as written (for example it uses levels and LARGE without self. or VehicleSize.). I did not copy it. I wrote and ran my own version. No numbers here are measured on real car parks; the prices are made-up examples.

Words used in this lesson

  • Strategy pattern means: put one decision behind an interface (a list of method names), and pass in different classes that do it differently. The caller does not care which one it got.
  • Decorator pattern means: a class that wraps another class with the same interface and adds a rule on top.
  • Dependency injection means: give a class the things it needs (its strategies, its clock) from outside, instead of letting it create them inside. This makes tests easy.
  • Compare-and-set (CAS) is one step that says "change this value to X only if it is still Y". The check and the change cannot be split apart.
  • Race condition is a bug that only appears when two pieces of work interleave in an unlucky order.

Step 1. Ask before you draw

The primer's version of the question starts with these questions and answers. They are a good model. I use them as my default list:

Question I askAnswer I assumeWhy it matters
Which vehicles?Motorcycle, car, truckDecides the size rules
Does each vehicle need a different spot?Yes: small, medium, large spotsThe "fits" table
More than one level?YesA spot needs a level and a distance
How do we charge?By the hour, part hours round upThe pricing rule
Who picks the spot, a person or the system?The system, at the entranceThe allocation strategy
Reservations in advance?No (an extension question)Keeps the first version small
One process or many?One process first, then discuss manyThe concurrency note

The primer notebook assumes motorcycle, car and bus, with a bus needing 5 consecutive large spots. I use "truck" and one spot each, to keep the code short. The 5-spots rule is an extension I discuss at the end.

Say the scope out loud: park, unpark, price, and tell me if the lot is full for this vehicle. No payment gateway, no UI, no cameras.

Step 2. Nouns become classes, verbs become methods

Noun in the storyClass or typeHoldsMain verbs
VehicleVehicle (a plain object: plate and type)plate, typenone, it is data
Parking spotSpotid, level, size, distance, who is in itfits, claim, release
The lotParkingLotall spots, active ticketspark, unpark, freeCount
TicketTicketid, plate, spot id, entry timenone, it is data
"Choose a spot"AllocationStrategynothingchoose
"Compute the fee"PricingStrategyratesfeeCents

Two choices here deserve a sentence each, because interviewers ask about them.

Why is Vehicle not a class tree (Car extends Vehicle)? In this problem the only difference between a car and a truck is which spot sizes they fit and what they pay. That is data, so I keep it in a table (FITS) and in the pricing rates. Three subclasses with no different behaviour would just be noise. If trucks later need real behaviour (for example "needs a ramp"), I can add it then. This is the "prefer composition to inheritance" idea from Lesson 0.

Why a distance on the spot instead of a Level class? Because the allocation strategies only need a number: how far is it from the entrance. A Level class is fine if you need per-level signs or per-level counters; I would add it when a requirement asks for it. Do not add classes that nothing uses.

Step 3. The full solution

The lot owns the spots and the tickets. Everything else it needs (two strategies and a clock) comes in through the constructor. That is dependency injection. Read it top to bottom; the two strategy interfaces are the heart of it.

JavaScript
export type VehicleType = "motorcycle" | "car" | "truck";export type SpotSize = "small" | "medium" | "large";export interface Vehicle {  plate: string;  type: VehicleType;}// Which spot sizes can hold which vehicle. One table, easy to change.export const FITS: Record<VehicleType, readonly SpotSize[]> = {  motorcycle: ["small", "medium", "large"],  car: ["medium", "large"],  truck: ["large"],};export class Spot {  occupiedBy: string | null = null; // plate, or null when free  readonly id: string;  readonly level: number;  readonly size: SpotSize;  readonly distance: number; // metres from the entrance  constructor(id: string, level: number, size: SpotSize, distance: number) {    this.id = id;    this.level = level;    this.size = size;    this.distance = distance;  }  get isFree(): boolean {    return this.occupiedBy === null;  }  fits(v: Vehicle): boolean {    return FITS[v.type].includes(this.size);  }  // Compare-and-set: succeeds only if the spot is still free right now.  claim(plate: string): boolean {    if (this.occupiedBy !== null) return false;    this.occupiedBy = plate;    return true;  }  release(): void {    this.occupiedBy = null;  }}// ---- Strategy 1: how to pick a spot ----export interface AllocationStrategy {  choose(candidates: Spot[], v: Vehicle): Spot | undefined;}// Candidates are already free and already fit the vehicle.export class NearestToEntrance implements AllocationStrategy {  choose(candidates: Spot[]): Spot | undefined {    return [...candidates].sort((a, b) => a.distance - b.distance)[0];  }}const SIZE_RANK: Record<SpotSize, number> = { small: 0, medium: 1, large: 2 };// Use the smallest spot that fits, so big spots stay free for big vehicles.export class BestFit implements AllocationStrategy {  choose(candidates: Spot[]): Spot | undefined {    return [...candidates].sort(      (a, b) => SIZE_RANK[a.size] - SIZE_RANK[b.size] || a.distance - b.distance,    )[0];  }}// ---- Strategy 2: how to price a stay ----export interface PricingStrategy {  feeCents(v: Vehicle, minutes: number): number;}export class HourlyPricing implements PricingStrategy {  private readonly centsPerHour: Record<VehicleType, number>;  private readonly graceMinutes: number;  constructor(centsPerHour: Record<VehicleType, number>, graceMinutes = 15) {    this.centsPerHour = centsPerHour;    this.graceMinutes = graceMinutes;  }  feeCents(v: Vehicle, minutes: number): number {    if (minutes <= this.graceMinutes) return 0;    return Math.ceil(minutes / 60) * this.centsPerHour[v.type]; // part hours round up  }}// Decorator: wraps any pricing and limits the total for one day.export class DailyCap implements PricingStrategy {  private readonly inner: PricingStrategy;  private readonly capCents: number;  constructor(inner: PricingStrategy, capCents: number) {    this.inner = inner;    this.capCents = capCents;  }  feeCents(v: Vehicle, minutes: number): number {    const days = Math.floor(minutes / 1440);    const rest = minutes % 1440;    return days * this.capCents + Math.min(this.inner.feeCents(v, rest), this.capCents);  }}export interface Ticket {  id: string;  plate: string;  spotId: string;  enteredAt: number; // ms since epoch, from the injected clock}export class ParkingLot {  private readonly active = new Map<string, Ticket>();  private readonly byPlate = new Map<string, string>(); // plate -> ticket id  private nextId = 1;  private readonly spots: Spot[];  private readonly allocation: AllocationStrategy;  private readonly pricing: PricingStrategy;  private readonly now: () => number;  constructor(spots: Spot[], allocation: AllocationStrategy, pricing: PricingStrategy, now: () => number = Date.now) {    this.spots = spots;    this.allocation = allocation;    this.pricing = pricing;    this.now = now;  }  freeCount(): number {    return this.spots.filter((s) => s.isFree).length;  }  park(v: Vehicle): Ticket | null {    if (this.byPlate.has(v.plate)) throw new Error(`already parked: ${v.plate}`);    const candidates = this.spots.filter((s) => s.isFree && s.fits(v));    const spot = this.allocation.choose(candidates, v);    if (!spot || !spot.claim(v.plate)) return null; // lot full for this vehicle    const ticket: Ticket = { id: `T${this.nextId++}`, plate: v.plate, spotId: spot.id, enteredAt: this.now() };    this.active.set(ticket.id, ticket);    this.byPlate.set(v.plate, ticket.id);    return ticket;  }  unpark(ticketId: string, v: Vehicle): { feeCents: number; minutes: number } {    const t = this.active.get(ticketId);    if (!t || t.plate !== v.plate) throw new Error(`unknown ticket: ${ticketId}`);    const minutes = Math.ceil((this.now() - t.enteredAt) / 60000);    const feeCents = this.pricing.feeCents(v, minutes);    this.spots.find((s) => s.id === t.spotId)!.release();    this.active.delete(ticketId);    this.byPlate.delete(t.plate);    return { feeCents, minutes };  }}

Things to point out in an interview:

  • FITS is one table. To allow a car in a small spot, you change one line.
  • park filters first, then asks the strategy. The strategy only sees spots that are free and big enough. So each strategy is a few lines and cannot return an illegal spot by mistake.
  • park returns null for "full", and throws for a real error (the same plate parking twice). A full lot is a normal answer, not an exception. This is a choice; say it is a choice.
  • The clock is injected. now defaults to Date.now, and tests pass their own function. Never call Date.now() deep inside business code if you want to test fees.
  • Money is in cents, as integers. Floating-point numbers like 0.1 + 0.2 are not exact, so never use them for money.
  • DailyCap is a decorator. It has the same interface as the thing it wraps, so new DailyCap(new HourlyPricing(...), 2000) is itself a pricing strategy.

Slot allocation strategies, compared

StrategyWhat it doesGood forCost
First freeTake the first free fitting spot in a fixed orderSimple, predictableFills the best spots first, may waste large spots on small vehicles
Nearest to entranceLowest distanceCustomer happiness, short walksBig vehicles can lose large spots to small ones
Best fitSmallest spot that fits, ties broken by distanceKeeps large spots free for trucksA motorcycle may get a far spot
Spread by levelPick the least-full levelEven load, less queueing on one rampMore walking

Both strategies sort a copy of the candidates (O(n log n) per park), which is fine for a few thousand spots. For a huge lot, keep a free list per size in a min-heap ordered by distance. I would say that, and not build it unless asked.

Now the demo. It uses a fake clock that I move forward by hand.

JavaScript
import { BestFit, DailyCap, HourlyPricing, ParkingLot, Spot } from "./lot.ts";let clock = Date.UTC(2026, 0, 1, 9, 0);const lot = new ParkingLot(  [    new Spot("L0-S1", 0, "small", 5),    new Spot("L0-M1", 0, "medium", 10),    new Spot("L0-M2", 0, "medium", 12),    new Spot("L1-L1", 1, "large", 40),  ],  new BestFit(),  new DailyCap(new HourlyPricing({ motorcycle: 100, car: 300, truck: 800 }), 2000),  () => clock,);const bike = { plate: "BIKE-1", type: "motorcycle" as const };const car = { plate: "CAR-1", type: "car" as const };const truck = { plate: "TRK-1", type: "truck" as const };const t1 = lot.park(bike)!;const t2 = lot.park(car)!;const t3 = lot.park(truck)!;console.log("bike  ->", t1.spotId); // smallest fit: the small spotconsole.log("car   ->", t2.spotId); // nearest mediumconsole.log("truck ->", t3.spotId); // only largeconsole.log("second truck ->", lot.park({ plate: "TRK-2", type: "truck" })); // null: no large leftconsole.log("free spots:", lot.freeCount());clock += 130 * 60000; // 2 h 10 min laterconsole.log("car leaves:", lot.unpark(t2.id, car));clock += 600 * 60000; // truck stays 12 h 10 min in totalconsole.log("truck leaves:", lot.unpark(t3.id, truck));console.log("free spots:", lot.freeCount());
TEXT
$ node demo.tsbike  -> L0-S1car   -> L0-M1truck -> L1-L1second truck -> nullfree spots: 1car leaves: { feeCents: 900, minutes: 130 }truck leaves: { feeCents: 2000, minutes: 730 }free spots: 3

Read the output like a story: the motorcycle took the small spot (best fit), the car took the nearer medium spot, the truck took the only large one, and a second truck got null. The car stayed 130 minutes and paid 900 cents: 3 hours (2 h 10 min rounds up) at 300 cents. The truck stayed 730 minutes. Uncapped that would be 13 hours at 800 cents, which is 10,400 cents, but the daily cap wrapper brought it down to 2,000.

Pricing strategies

Pricing is a second place where requirements change often: weekend rates, first hour free, monthly passes, electric-vehicle discounts. Putting it behind PricingStrategy means each new rule is a new class, and ParkingLot does not change. That is the open/closed principle from Lesson 0: open for new behaviour, closed for edits.

RuleHow I would model it
Hourly, part hours round upHourlyPricing (done above)
Maximum per dayDailyCap decorator (done above)
First 15 minutes freeThe graceMinutes argument
Different rate on weekendsA WeekendPricing that picks between two inner strategies by date, so it needs the entry time. I would change the method to feeCents(v, enteredAt, leftAt).

Note the weekend row. A strategy interface should carry the information the rules need. If a new rule needs more input, you change the interface once. Saying "I would change the signature" is a better answer than hiding the entry time in a global.

Concurrency: the double-booking note

In Node, park is a normal synchronous function, so nothing can run in the middle of it. Two calls to park cannot interleave. That is why the code above is safe as written, and you can say that.

But a real lot has several entrance gates, and each gate is a different process or a different request handler. As soon as there is a gap between "look" and "write", the bug appears. The gap can be an await (a database call, a payment authorisation). This is the check-then-act race. I made it happen on purpose:

JavaScript
import { Spot } from "./lot.ts";const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));const spot = new Spot("S1", 0, "medium", 1);// WRONG: check, then await, then act. Another request can run in the gap.async function parkUnsafe(plate: string): Promise<boolean> {  if (!spot.isFree) return false;  await sleep(10); // stands in for a database call or a payment call  spot.occupiedBy = plate; // blind write  return true;}// RIGHT: the check and the write are one step (compare-and-set).async function parkSafe(plate: string): Promise<boolean> {  await sleep(10); // slow work happens BEFORE the claim  return spot.claim(plate);}console.log("unsafe:", await Promise.all([parkUnsafe("AAA"), parkUnsafe("BBB")]));spot.release();console.log("safe:  ", await Promise.all([parkSafe("AAA"), parkSafe("BBB")]), "owner =", spot.occupiedBy);
TEXT
$ node race.tsunsafe: [ true, true ]safe:   [ true, false ] owner = AAA

unsafe: [ true, true ] means both cars were told they got the same spot. The fix is not "add a lock everywhere". The fix is to make the check and the write one step: claim is a compare-and-set, and the slow work happens before it. The second caller gets false and should go back and choose another spot (a retry loop).

In a real system with a database, the same idea has the same shape:

Where the state livesOne-step claim
In memory, one processA synchronous method (as above), or a mutex around check-and-write
SQL databaseUPDATE spots SET plate = $1 WHERE id = $2 AND plate IS NULL and check that 1 row changed
RedisSET spot:42 plate NX (set only if it does not exist)
Many lots, many gatesSame as the database case. The database is the single place that decides

I did not run the SQL or Redis lines in this lesson; they are the standard pattern, so test them on your own database. For the distributed side (locks, leases, fencing tokens) see the system design series.

Do not hold a lock while you wait for a person or a payment. Reserve the spot with a short expiry (a hold), pay, then confirm. If the hold runs out, the spot goes back. Locks held across slow work are how lots, seats and tickets get stuck.

Testing it

The design is easy to test because the lot has no hidden inputs. The clock, the allocation and the pricing all come in from outside. These are the tests I ran:

JavaScript
import { test } from "node:test";import assert from "node:assert/strict";import { BestFit, HourlyPricing, DailyCap, NearestToEntrance, ParkingLot, Spot } from "./lot.ts";const rates = { motorcycle: 100, car: 300, truck: 800 };function make(clock = { t: 0 }) {  const spots = [    new Spot("S", 0, "small", 5),    new Spot("M", 0, "medium", 10),    new Spot("L", 0, "large", 1),  ];  const lot = new ParkingLot(spots, new BestFit(), new HourlyPricing(rates), () => clock.t);  return { lot, spots, clock };}const car = { plate: "C1", type: "car" as const };test("best fit gives a motorcycle the small spot, not the nearest one", () => {  const { lot } = make();  assert.equal(lot.park({ plate: "B1", type: "motorcycle" })!.spotId, "S");});test("nearest-to-entrance strategy picks by distance instead", () => {  const spots = [new Spot("far", 0, "medium", 50), new Spot("near", 0, "medium", 2)];  const lot = new ParkingLot(spots, new NearestToEntrance(), new HourlyPricing(rates));  assert.equal(lot.park(car)!.spotId, "near");});test("returns null when no free spot fits", () => {  const { lot } = make();  lot.park(car);  lot.park({ plate: "C2", type: "car" }); // takes the large spot  assert.equal(lot.park({ plate: "C3", type: "car" }), null);  assert.equal(lot.park({ plate: "T1", type: "truck" }), null);});test("the same plate cannot park twice", () => {  const { lot } = make();  lot.park(car);  assert.throws(() => lot.park(car), /already parked/);});
TEXT
$ node --test --test-reporter=spec lot.test.ts | LC_ALL=C sed 's/\xE2\x9C\x94/pass/; s/\xE2\x84\xB9/#/'pass best fit gives a motorcycle the small spot, not the nearest one (1.44455ms)pass nearest-to-entrance strategy picks by distance instead (0.303418ms)pass returns null when no free spot fits (0.279795ms)pass the same plate cannot park twice (0.522371ms)pass unpark frees the spot and charges part hours as full hours (1.639155ms)pass grace period is free (0.240133ms)pass unknown or wrong ticket is rejected (0.267712ms)pass daily cap wraps any pricing (0.221574ms)# tests 8# suites 0# pass 8# fail 0# cancelled 0# skipped 0# todo 0# duration_ms 205.56542

I show the first four tests only; the other four (rounding, grace period, bad ticket, daily cap) follow the same shape, and all eight are in the output. I test behaviour, not private fields, and I do not mock Spot, because a real one is cheap to build.

Extension questions you should expect

They askShort answer
A bus needs 5 consecutive large spotsSpots on a level must be ordered. choose becomes "find a run of 5 free large spots". A vehicle then holds a list of spots, and the ticket stores all ids. This is the case in the primer notebook.
ReservationsA Reservation with a time window. A spot is free for a new car only if no reservation overlaps the stay. Needs an interval check (see the interval lessons in DSA).
Electric-vehicle chargersNew spot attribute hasCharger, and a FITS-style rule. Allocation strategy prefers non-charger spots for non-EV cars.
PaymentA PaymentGateway interface, injected into unpark. Charge with an idempotency key so a retry does not charge twice (Lesson 9).

Interview questions

Walk me through your classes for a parking lot. (Must know)

I start with the scope: park, unpark, price, full. The nouns are vehicle, spot, ticket and the lot. Spot knows its size, level, distance and who is in it. ParkingLot owns the spots and the active tickets. The two decisions that change most are which spot and what price, so I put each behind an interface: AllocationStrategy and PricingStrategy. I inject them, plus a clock, through the constructor.

Why not make Car, Motorcycle and Truck subclasses of Vehicle? (Senior signal)

Because they do not behave differently here. They only differ in data: which spot sizes they fit and what rate they pay. A table and a rate map handle that. Empty subclasses add files and no value. If a real behavioural difference appears later, I can add a subclass then.

How do you pick the spot? (Must know)

With a strategy. The lot first filters to spots that are free and big enough, then asks the strategy to choose. I would offer best fit (smallest spot that fits, so trucks are not blocked) and nearest to entrance. I would say the trade-off: nearest is nice for drivers but can waste large spots, and best fit keeps large spots for trucks.

Where would you use the strategy pattern, and where not? (Must know)

For decisions with several valid answers that change often: spot choice, pricing. Not for something with one answer. A strategy for "how to add two numbers" is just noise. A good test: can I name two real variants today, or one likely one next quarter?

How do you handle two cars taking the same spot? (Must know)

In one single-threaded process with a synchronous park, it cannot happen. With several gates, the check and the write must be one atomic step. In memory that is a compare-and-set method. In SQL it is UPDATE ... WHERE plate IS NULL and checking that one row changed. The loser retries with another spot. I would also say I demonstrated the race with an await between check and write, and both callers got true.

Why return null instead of throwing when the lot is full? (Trap)

A full lot is a normal outcome, not a bug, and the caller must handle it (show "full", offer another lot). Throwing is for breaking the rules, like parking the same plate twice. But there is no single right answer; some teams return a result type like { ok: false, reason: "FULL" }. The trap is not having an opinion. I pick one, say why, and stay consistent.

How do you test fees without waiting an hour? (Must know)

I inject the clock. ParkingLot takes a now function. In tests I pass a function that returns a variable, and I move the variable forward by 130 minutes. No sleeping, no flaky tests.

What changes if the interviewer adds weekend prices? (Senior signal)

Only the pricing side. I write a new strategy. The pricing method needs the entry and exit times, not just the minutes, so I change the signature once. ParkingLot and the allocation code do not change. If I find myself editing ParkingLot for a pricing rule, my design was wrong.

How would you scale choose to a very large lot? (Senior signal)

I would stop scanning all spots. I would keep a free list per size, ordered by distance (a min-heap, or a sorted set). park takes the best item in O(log n). release puts the spot back. I would say this is only needed if the number of spots is huge, since for a few thousand spots a scan is fine and simpler.

Is the lot a singleton? (Trap)

It is tempting because "there is one lot". But a global makes tests share state, and one company can own several lots. I create the lot in the composition root (the place where the app wires objects together) and pass it where needed. One instance, created once, without a global. See the singleton discussion in Lesson 1 and the anti-patterns in Lesson 4.

Key takeaways

  • Ask first. Scope: park, unpark, price, full. Write the assumptions down.
  • Nouns become classes, but only if they have behaviour. A vehicle type is data, not a class tree.
  • Put the two changing decisions, spot choice and price, behind interfaces. Inject them with the clock.
  • A decorator (DailyCap) adds a rule without touching the thing it wraps.
  • A full lot is a normal answer (null), a rule break is an exception. Pick, and say why.
  • Check-then-act is a race as soon as there is a gap. Make it one step: compare-and-set in memory, a conditional UPDATE in SQL.
  • Money in integer cents. Time from an injected clock.
  • Be ready for: buses, reservations, chargers, display boards, multiple lots, payments.

What's next

Next: Lesson 6, Design an LRU Cache and a Hash Map. Practise: add the bus. Give Spot an index in its level, make choose find 5 free large spots in a row, make the ticket hold a list of spot ids, and write the three tests first.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related lessons

Part of the study guide

Related series