LLD Case Study: Designing a Parking Lot

October 17, 2026 · 10 min read

"Design a parking lot" is asked in low-level design interviews more than any other problem. It's small enough to finish in 45 minutes, and it tests most of what interviewers look for: identifying entities, assigning responsibilities, choosing relationships, and leaving the right things easy to change.

1. Clarify the requirements

Ask before designing. A reasonable scope:

  • Several levels, each with spots of three sizes: small (motorcycle), medium (car), large (bus/truck).
  • A vehicle fits in a spot of its size or larger.
  • On entry, assign a spot and issue a ticket; on exit, charge a fee by time parked, then free the spot.
  • Show free spots per level and size.
  • Pricing rules will change (hourly, weekend flat rate, first hour free).

Out of scope, said out loud: reservations, payment processing details, license plate recognition, multiple lots. Writing these down takes a minute and gives you something to point at when the interviewer later asks for one of them as an extension — you've already shown where it would plug in.

2. Find the entities

Underline the nouns: lot, level, spot, vehicle, ticket, gate, fee. Then decide what each is responsible for:

classresponsibility
ParkingLotentry point: park(vehicle) → Ticket, leave(ticket) → fee
Levelowns its spots; reports availability
ParkingSpotsize and state (FREE / OCCUPIED); atomic reserve/release
Vehicleplate and size
Ticketvehicle, spot, entry time, status
SpotAllocatorinterface: choose a spot for a vehicle
PricingPolicyinterface: compute the fee for a ticket

3. Choose relationships deliberately

  • ParkingLot composes Levels, which compose ParkingSpots: spots don't exist without their level.
  • Vehicle types: a Car extends Vehicle hierarchy is the reflex, but the only thing that differs is size. A size field — an enum — is simpler, and adding an electric car later doesn't need a subclass. This is composition over inheritance in practice. (If vehicle types later behave differently — EVs needing charging spots — revisit.)
  • SpotAllocator and PricingPolicy are interfaces: the two things the requirements say will vary. That's the Strategy pattern.

4. The core flow

entry gate
ParkingLot
SpotAllocator
Level 2
Ticket
park(car KA-01-1234)
▶
classes
ParkingLot → Level → ParkingSpot Vehicle: size (SMALL | MEDIUM | LARGE) SpotAllocator, PricingPolicy (interfaces)

A car arrives at the entry gate. The gate knows nothing about levels or spots — it asks the parking lot.

0 / 6
enum Size { SMALL = 0, MEDIUM = 1, LARGE = 2 }

class ParkingSpot {
  private occupied = false
  constructor(readonly id: string, readonly size: Size) {}
  fits(v: Vehicle) { return this.size >= v.size }
  isFree() { return !this.occupied }
  tryReserve(): boolean {            // must be atomic — see below
    if (this.occupied) return false
    this.occupied = true
    return true
  }
  release() { this.occupied = false }
}

interface SpotAllocator {
  candidates(levels: Level[], v: Vehicle): Iterable<ParkingSpot>
}

interface PricingPolicy {
  fee(ticket: Ticket, exit: Date): number
}

class ParkingLot {
  private active = new Map<string, Ticket>()
  constructor(
    private levels: Level[],
    private allocator: SpotAllocator,
    private pricing: PricingPolicy,
  ) {}

  park(v: Vehicle, now = new Date()): Ticket {
    for (const spot of this.allocator.candidates(this.levels, v)) {
      if (spot.fits(v) && spot.tryReserve()) {
        const ticket = new Ticket(crypto.randomUUID(), v, spot, now)
        this.active.set(ticket.id, ticket)
        return ticket
      }
    }
    throw new LotFullError(v.size)
  }

  leave(ticketId: string, now = new Date()): number {
    const ticket = this.active.get(ticketId)
    if (!ticket) throw new UnknownTicketError(ticketId)
    const fee = this.pricing.fee(ticket, now)
    ticket.close()                   // throws if already closed
    ticket.spot.release()
    this.active.delete(ticketId)
    return fee
  }
}

Notice what ParkingLot doesn't know: which spot is "best", or how fees are computed. Both come in through the constructor — plain dependency injection — so tests can pass a fixed allocator and a fake clock.

The supporting classes

The rest of the model is deliberately small:

class Vehicle {
  constructor(readonly plate: string, readonly size: Size) {}
}

class Level {
  constructor(readonly number: number, readonly spots: ParkingSpot[]) {}
  freeCount(size: Size) {
    return this.spots.filter((s) => s.size === size && s.isFree()).length
  }
}

enum TicketStatus { ACTIVE, PAID, LOST }

class Ticket {
  status = TicketStatus.ACTIVE
  constructor(
    readonly id: string,
    readonly vehicle: Vehicle,
    readonly spot: ParkingSpot,
    readonly entryTime: Date,
  ) {}
  close() {
    if (this.status !== TicketStatus.ACTIVE) throw new Error('ticket already closed')
    this.status = TicketStatus.PAID
  }
}

The Ticket guards its own transitions: closing a closed ticket throws, so a double "leave" can't free a spot twice. It's a tiny state machine, and making the invalid transition impossible in the class is better than checking for it in every caller.

Two allocators

The allocator decides which order to try spots in. Two common policies:

// Smallest spot that fits, lowest level first — keeps large spots
// free for large vehicles.
class BestFitAllocator implements SpotAllocator {
  *candidates(levels: Level[], v: Vehicle) {
    for (let size = v.size; size <= Size.LARGE; size++) {
      for (const level of levels) {
        for (const spot of level.spots) {
          if (spot.size === size && spot.isFree()) yield spot
        }
      }
    }
  }
}

// Closest to a given entrance — needs distances precomputed per gate.
class NearestToGateAllocator implements SpotAllocator {
  constructor(private distances: Map<string, number>) {}
  *candidates(levels: Level[], v: Vehicle) {
    const free = levels.flatMap((l) => l.spots)
      .filter((s) => s.fits(v) && s.isFree())
      .sort((a, b) => this.distances.get(a.id)! - this.distances.get(b.id)!)
    yield* free
  }
}

Returning an iterable rather than a single spot is a small design choice with a big payoff: if the first candidate is taken by a concurrent arrival between suggestion and reservation, park simply tries the next one. The allocator never needs to know about the race.

At scale, scanning every spot on every arrival is wasteful. Keep a set of free spot IDs per (level, size), updated on reserve and release, and the best-fit allocator becomes a few set lookups. Interviewers like hearing that you'd start simple and know where the hot path would need an index.

Pricing, composed

PricingPolicy implementations stay small when each rule is its own class, and rules are combined with decorators:

class HourlyPricing implements PricingPolicy {
  constructor(private ratePerHour: Record<Size, number>) {}
  fee(t: Ticket, exit: Date) {
    const hours = Math.ceil((exit.getTime() - t.entryTime.getTime()) / 3_600_000)
    return hours * this.ratePerHour[t.vehicle.size]
  }
}

class FirstHourFree implements PricingPolicy {
  constructor(private inner: PricingPolicy) {}
  fee(t: Ticket, exit: Date) {
    const oneHourLater = new Date(t.entryTime.getTime() + 3_600_000)
    return exit <= oneHourLater ? 0 : this.inner.fee(t, exit)
  }
}

class DailyCap implements PricingPolicy {
  constructor(private inner: PricingPolicy, private cap: number) {}
  fee(t: Ticket, exit: Date) {
    return Math.min(this.cap, this.inner.fee(t, exit))
  }
}

const pricing = new DailyCap(new FirstHourFree(new HourlyPricing(RATES)), 500)

A new rule — weekend flat rate, a discount for EVs — is a new class and a different line of composition. Nothing in ParkingLot changes, which is the open–closed principle doing its job. (DailyCap as written caps the whole stay; a "per day" cap for multi-day stays would split the stay by calendar day first — exactly the kind of clarifying question to ask.)

5. Concurrency

Two cars arriving at two gates can pick the same free spot. The check ("is it free?") and the update ("mark it occupied") must be one atomic step:

  • In memory, multi-threaded (Java, Go): compare-and-set on an atomic flag, or a lock per level.
  • With a database: a conditional update — UPDATE spots SET occupied = true WHERE id = ? AND occupied = false — and check that one row changed. If not, try the next candidate.

The allocator's candidates are just suggestions. tryReserve is the source of truth. Mentioning this race unprompted is one of the clearest signals of a strong answer. In JavaScript's single-threaded runtime the in-memory version is safe as written, because nothing interleaves between the check and the set — but say that explicitly, and say what changes the moment the state moves to a shared database.

6. Extensions to discuss

  • New pricing: weekend flat rate, first hour free, a cap per day — each a new PricingPolicy, or a decorator around an existing one.
  • EV charging spots: a capability on the spot, and an allocator that prefers them for EVs.
  • Availability display: keep per-level counters updated on reserve and release, or publish events that a display board observes.
  • Lost ticket: look up by plate, charge a maximum fee.

The class diagram, in words

Interviewers usually want a diagram. The relationships, stated precisely:

ParkingLot ◆──── 1..* Level ◆──── 1..* ParkingSpot
ParkingLot ────> SpotAllocator  «interface»  ◁── BestFitAllocator, NearestToGateAllocator
ParkingLot ────> PricingPolicy  «interface»  ◁── HourlyPricing, FirstHourFree, DailyCap
ParkingLot ◆──── 0..* Ticket (active)
Ticket ────> Vehicle,  Ticket ────> ParkingSpot

Filled diamonds are composition (the lot owns levels and active tickets; they don't outlive it). Plain arrows are associations or dependencies on interfaces. Open triangles are "implements". Being able to say why each line is the kind it is — "a spot can't exist without its level, so that's composition" — matters more than drawing UML notation perfectly.

Persisting it

An in-memory model is what interviews ask for, but the follow-up is often "how would you store this?". The tables mirror the classes, with the concurrency rule moved into the database:

CREATE TABLE spots (
  id        text PRIMARY KEY,
  level     int  NOT NULL,
  size      smallint NOT NULL,         -- 0 small, 1 medium, 2 large
  ticket_id uuid UNIQUE                -- NULL when free
);

CREATE TABLE tickets (
  id         uuid PRIMARY KEY,
  plate      text NOT NULL,
  size       smallint NOT NULL,
  spot_id    text NOT NULL REFERENCES spots(id),
  entry_time timestamptz NOT NULL,
  exit_time  timestamptz,
  fee        numeric,
  status     text NOT NULL DEFAULT 'ACTIVE'
);

-- one active ticket per plate: a car can't be parked twice
CREATE UNIQUE INDEX one_active_ticket ON tickets (plate) WHERE status = 'ACTIVE';

Reserving a spot becomes the conditional update from the concurrency section (... SET ticket_id = $1 WHERE id = $2 AND ticket_id IS NULL), inside the same transaction that inserts the ticket. The partial unique index enforces a business rule — one active ticket per plate — that the in-memory version didn't even have, with no application code. That's a good point to raise: some invariants are cheaper and safer in the schema.

7. Testing the design

The design choices pay off in tests. Because the clock, allocator and pricing are injected, the important behaviours are short, deterministic tests:

test('a car takes a large spot when medium spots are full', () => {
  const level = new Level(1, [spot('M1', Size.MEDIUM, { occupied: true }), spot('L1', Size.LARGE)])
  const lot = new ParkingLot([level], new BestFitAllocator(), freePricing)
  expect(lot.park(car()).spot.id).toBe('L1')
})

test('first hour is free, then hourly', () => {
  const t = ticketAt('2026-10-17T10:00')
  expect(pricing.fee(t, at('2026-10-17T10:59'))).toBe(0)
  expect(pricing.fee(t, at('2026-10-17T12:30'))).toBe(3 * RATES[Size.MEDIUM])
})

test('leaving twice does not free the spot twice', () => {
  const ticket = lot.park(car())
  lot.leave(ticket.id)
  expect(() => lot.leave(ticket.id)).toThrow()
})

None of these needs a real clock, a database or a sleep — a sign the responsibilities are in the right places.

8. Mistakes that cost points

  • A ParkingLot god class that knows spot sizes, pricing rules and display logic. Push each concern to the class that owns the data.
  • A deep vehicle hierarchy (Car, Bike, Truck, ElectricCar…) where the subclasses differ only by a constant.
  • Pricing as a switch on vehicle type inside leave. Every new rule edits the same method.
  • Ignoring the race between two gates choosing the same spot.
  • Singletons everywhere. ParkingLot.getInstance() is a common reflex, and it makes testing with different configurations harder. If there's one lot per process, create one and pass it in.

9. Questions the interviewer may add

  • "Support reservations." A spot gains a RESERVED state with an expiry; tryReserve respects it; a sweeper releases expired holds.
  • "Multiple entrances and exits." Gates hold a reference to the lot and an allocator configured with their own distances. The concurrency section is what makes this safe.
  • "Payment methods." Another strategy — PaymentProcessor — called in leave, with the ticket marked paid only after payment succeeds.
  • "Scale to many lots." ParkingLot instances keyed by lot ID behind a service; the class design doesn't change, the persistence does.
  • "Show free spots on a board at each entrance." Spots publish reserved and released events; each board subscribes and keeps its own counts. The lot doesn't know boards exist — the observer pattern, and a natural answer to "how would you add this without touching ParkingLot?".

Practise it

This is the first problem in the LLD practice tool. Write your notes, draw the class diagram — ParkingLot composing Levels, the strategies as interfaces — and get it reviewed by Claude against the same rubric used here.