Engineering Atlas › Low-Level Design

Code a parking lot with tickets and fees

17 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional:

Non-functional:

Out of scope for the interview: online reservations, monthly passes, EV charging and licence-plate recognition. Mention them, park them in Extensibility, and move on.

Entities and relationships

Start with nouns and ask which ones hold state that changes.

Relationships: a lot is composed of levels, a level of spots (they do not exist independently). A ticket references a spot and a vehicle but does not own them. The lot aggregates active tickets keyed by ticket id, plus a set of plates currently inside so the same car cannot be admitted twice.

Class diagram

classDiagram
    class ParkingLot {
      -levels: Level[]
      -active: Map~TicketId, Ticket~
      +park(plate, size) Ticket
      +unpark(ticketId) Money
      +freeCount(size) int
    }
    class Level {
      +number: int
      -freeBySize: Map~Size, SpotId[]~
    }
    class ParkingSpot {
      +id: SpotId
      +size: Size
      +occupant: TicketId
    }
    class Ticket {
      +id: TicketId
      +plate: string
      +size: Size
      +spotId: SpotId
      +entryTime: int
      +status: TicketStatus
    }
    class AllocationStrategy {
      <<interface>>
      +pick(levels, size) ParkingSpot
    }
    class PricingStrategy {
      <<interface>>
      +fee(ticket, exitTime) Money
    }
    class Clock {
      <<interface>>
      +now() int
    }
    ParkingLot "1" *-- "many" Level
    Level "1" *-- "many" ParkingSpot
    ParkingLot "1" o-- "many" Ticket
    Ticket --> ParkingSpot
    ParkingLot --> AllocationStrategy
    ParkingLot --> PricingStrategy
    ParkingLot --> Clock
    SmallestFit ..|> AllocationStrategy
    HourlyRate ..|> PricingStrategy

Key flows

Entry and exit share the same lock on the lot. Pricing happens outside the critical section in a real system, because it is a pure function of the ticket and the exit time.

sequenceDiagram
    participant D as Driver
    participant G as EntryGate
    participant L as ParkingLot
    participant A as AllocationStrategy
    participant X as ExitGate
    participant P as PricingStrategy
    D->>G: arrive (plate, size)
    G->>L: park(plate, size)
    L->>L: lock
    L->>A: pick(levels, size)
    A-->>L: spot L1-007
    L->>L: mark spot taken, record ticket, unlock
    L-->>G: Ticket T42
    G-->>D: print ticket, raise barrier
    D->>X: present T42
    X->>L: unpark(T42)
    L->>L: lock, close ticket, free spot, unlock
    L->>P: fee(ticket, now)
    P-->>L: 600 cents
    L-->>X: amount due
    X-->>D: pay, raise barrier

Design patterns used and why

Concurrency and edge cases

In the wild

Down the stack

Layer 1The contract

park either returns a ticket bound to exactly one spot that no other active ticket holds, or returns "full" and changes nothing. unpark on an active ticket frees that spot and returns fee(size, exit - entry); on an unknown or closed ticket it fails and changes nothing. freeCount is exact at the moment the lock is held and may be stale by the time a display shows it, which is acceptable because the gate re-checks.

Layer 2The mechanism

In memory, keep a free list per spot size, sorted by spot id so "lowest level first" falls out of the ordering, plus a hash map from ticket id to ticket and a set of plates. Park walks sizes from the vehicle's own upwards and pops the first free id, which is O(number of sizes) plus the cost of the list operation. A min-heap per size makes it O(log n). In a real garage this state lives in a database: one row per spot with a nullable ticket_id and an index on (size, ticket_id, level). A Postgres claim looks like SELECT id FROM spot WHERE size >= $1 AND ticket_id IS NULL ORDER BY size, id LIMIT 1 FOR UPDATE SKIP LOCKED, followed by the update, in one transaction. SKIP LOCKED lets two gates claim two different spots in parallel instead of queueing on the same row. The cost is a deliberately inconsistent view: rows another transaction holds are simply left out, which Postgres says suits queue-like tables, not general queries (Postgres docs). So a gate can see "full" while another gate's claim on the last spot is about to roll back, and the right reaction is a quick retry, not a "lot full" sign.

Layer 3The machine

An uncontended sync.Mutex in Go costs around 20 ns; a contended one parks the goroutine through a futex syscall, a few microseconds. A Postgres commit waits for the write-ahead log fsync, around 0.1 to 1 ms on an NVMe SSD. Compare the physical world: a loop detector needs tens of milliseconds to register a car, a barrier arm 2 to 6 seconds to rise, and 10 gates see at most a few arrivals per second. The software is three to four orders of magnitude faster than the hardware it serves, so a single global lock is not a bottleneck. On clocks: the wall clock can jump when NTP corrects it, so production code measures durations with a monotonic clock where it can and stores wall-clock timestamps only for receipts. Go builds this in: time.Now() carries both readings, and Sub uses the monotonic one when both values have it (Go time docs). A monotonic reading does not survive a process restart, though, so a ticket stored in a database still needs a wall-clock entry time and a clamp at zero.

Why it's built this way

  1. Spots are physical and scarce, so double allocation is a customer-facing failure: two cars, one space, an angry attendant.
  2. Preventing it needs one serialization point per spot, so selection and claim happen under one lock or one row lock.
  3. Gates arrive a few times per second at most, so a coarse lock is cheap and you do not need lock-free cleverness.
  4. Fees are disputed, so the fee must be recomputable from the ticket alone, which forces an injected clock and integer money.
  5. Policies change monthly while spots change almost never, so policies sit behind interfaces and the spot index stays simple.

Extensibility

Variant: parcel locker station

A parcel locker is the same allocator with three changes: the occupant is a package instead of a driver, the exit is authorised by a secret instead of a ticket, and a deadline ends the stay. Compartments come in three sizes and the smallest-fit rule carries over unchanged, so ParkingLot becomes LockerStation with deposit, pickup(code) and collectExpired. The domain also gains a hardware boundary, so a DoorController interface (open, isClosed) sits beside the clock, with a fake for tests and one adapter per lock-board vendor.

The ticket becomes a bearer credential. A parking ticket is useless to a stranger because it is tied to a plate and a fee. A pickup code opens a door for whoever types it, so it needs properties the ticket never had. Draw it from a cryptographic source (Python's secrets, Go's crypto/rand, not random), redraw on collision with any active code, and store only a salted hash. Six digits give a million values, so a station holding 100 parcels has a 1 in 10,000 chance that a fresh code collides, and a stranger's random guess hits some live code about 100 times in a million. Count consecutive wrong attempts and lock the keypad for a few minutes after, say, five (OWASP guidance). Because the attacker must stand at one visible screen, this is enough here, where an online login needs much more.

The stay has a deadline. Parking is open-ended and billed at exit. A locker stay is capped, three days for Amazon's lockers, and an uncollected parcel blocks a scarce door. Each assignment stores a deadline, an expired code stops opening the door even though the parcel is still inside, and a sweep returns each expired package exactly once to the courier. Keep a min-heap by deadline so the sweep touches only what is due.

Compartment state needs more than free or taken. Use AVAILABLE, RESERVED, OCCUPIED and OUT_OF_SERVICE. The code is issued only after the door reports closed; until then the box may be empty or the courier may cancel. A door that never confirms closing sends the compartment to inspection and the courier to another box.

Offline is normal. A station in a petrol station loses its network, so it validates pickups from locally stored hashes, writes state to flash with fsync on every change, and syncs events later. The station, not the cloud, is the source of truth for which door is occupied.

Leadership lens

Check yourself

  1. Why is an enum for vehicle size better than Car, Bus and Motorcycle subclasses in this design?

    Show answer

    The only behaviour that varies is size, and an enum captures that with no dispatch or casting. Subclasses earn their place when types gain behaviour of their own, such as an EV needing a charger.

  2. Two gates call park for the last free spot at the same time. What exactly must be atomic?

    Show answer

    Choosing the spot and marking it taken (plus recording the ticket). If those are separate steps, both callers can choose the same spot before either marks it.

  3. Why should the pricing code not call the system clock itself?

    Show answer

    Then fees cannot be tested deterministically, replayed for a dispute, or computed consistently with the entry time. Injecting a clock makes the fee a pure function of recorded facts.

  4. A car parks in a large spot because no medium spot is free. Which rate does it pay, and where does that rule live?

    Show answer

    That is a policy decision; the usual choice is the vehicle's rate, not the spot's. It lives in the pricing strategy, keyed by the size stored on the ticket.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →