Parking lot LLD deep dive

Design a parking lot

Parking lot LLD: motorcycle/car/large spots, ticket on enter, hourly fee in cents with round-up, relational occupancy set, enter/exit APIs, verification, and multi-floor / concurrency extensions.

Assign a spot, charge on exit

A parking lot assigns a compatible free spot when a vehicle enters, issues a ticket, and on exit validates the ticket, computes an hourly fee, and frees the spot. The classic prompt is intentionally vague — you invent the concrete v1.
01Types

Moto · Car · Large

02Enter

Spot + ticket

03Fee

Hourly cents

04Reject

Full / bad ticket

Parking lot entities
ParkingLot orchestrates; ParkingSpot is typed capacity; Ticket records the visit.

Analogy: attendant with one clipboard

Parking clipboard lock
One clipboard = one lot lock.

The lot attendant has one clipboard. Finding a free spot and scribbling “occupied” happen before the clipboard is passed to the next attendant. Two gates without a clipboard both assign spot S — classic race.

Clarifying questions → locked requirements

  • Vehicle types? → Motorcycle, car, large (SUV/van).
  • Who picks the spot? → System assigns a compatible free spot.
  • Proof of parking? → Ticket with unique id at entry.
  • Pricing? → Single hourly rate; round up to next hour; store money as integer cents, not floats.
  • Full lot / bad ticket? → Reject entry / reject exit clearly.

Entities: ParkingLot, ParkingSpot, Ticket — Vehicle is an enum

Vehicle is not a class. The lot doesn’t manage vehicle lifecycle — it only needs a type for spot matching. Use an enum. Ticket is an entity: the lot creates and owns the session record.
  • ParkingLot — spots list, occupied_spot_ids: set, tickets: dict[id, Ticket], hourly_rate_cents.
  • ParkingSpot — id + spot type (compatible vehicles).
  • Ticket — id, spot id, vehicle type, entry timestamp.

Class design

from dataclasses import dataclass
from enum import Enum, auto
from datetime import datetime
from typing import Optional
import math, uuid

class VehicleType(Enum):
    MOTORCYCLE = auto()
    CAR = auto()
    LARGE = auto()

class ParkingSpot:
    def __init__(self, spot_id: str, spot_type: VehicleType):
        self.id, self.spot_type = spot_id, spot_type

    def fits(self, vehicle: VehicleType) -> bool:
        # v1: exact type match (or define size ordering if interviewer prefers)
        return self.spot_type is vehicle

@dataclass
class Ticket:
    id: str
    spot_id: str
    vehicle_type: VehicleType
    entry_time: datetime

class ParkingLot:
    def __init__(self, spots: list[ParkingSpot], hourly_rate_cents: int):
        self.spots = {s.id: s for s in spots}
        self.occupied_spot_ids: set[str] = set()
        self.tickets: dict[str, Ticket] = {}
        self.hourly_rate_cents = hourly_rate_cents

    def enter(self, vehicle_type: VehicleType) -> Ticket: ...
    def exit(self, ticket_id: str, exit_time: Optional[datetime] = None) -> int: ...
public enum VehicleType { MOTORCYCLE, CAR, LARGE }
public enum SpotType { MOTORCYCLE, COMPACT, LARGE }

public class ParkingSpot {
    public final String id;
    public final SpotType spotType;
    public ParkingSpot(String id, SpotType spotType) {
        this.id = id; this.spotType = spotType;
    }
    public boolean fits(VehicleType v) { /* compatibility table */ return true; }
}

public class Ticket {
    public final String id;
    public final String spotId;
    public final VehicleType vehicleType;
    public final Instant entryTime;
    public Ticket(String id, String spotId, VehicleType vehicleType, Instant entryTime) {
        this.id = id; this.spotId = spotId;
        this.vehicleType = vehicleType; this.entryTime = entryTime;
    }
}

public class ParkingLot {
    private final Map<String, ParkingSpot> spots = new HashMap<>();
    private final Set<String> occupiedSpotIds = new HashSet<>();
    private final Map<String, Ticket> tickets = new HashMap<>();
    private final int hourlyRateCents;
    public ParkingLot(List<ParkingSpot> spots, int hourlyRateCents) { /* ... */ this.hourlyRateCents = hourlyRateCents; }
    public Ticket enter(VehicleType vehicleType) { /* find + occupy + ticket */ throw new UnsupportedOperationException(); }
    public int exit(String ticketId) { /* pop + fee */ throw new UnsupportedOperationException(); }
}

enter, exit, findAvailableSpot, computeFee

def find_available_spot(self, vehicle_type: VehicleType) -> Optional[ParkingSpot]:
    for spot in self.spots.values():
        if spot.id in self.occupied_spot_ids:
            continue
        if spot.fits(vehicle_type):
            return spot
    return None

def compute_fee(self, entry: datetime, exit: datetime) -> int:
    seconds = max(0, (exit - entry).total_seconds())
    hours = max(1, math.ceil(seconds / 3600))  # round up; minimum 1 hour
    return hours * self.hourly_rate_cents

def enter(self, vehicle_type: VehicleType) -> Ticket:
    spot = self.find_available_spot(vehicle_type)
    if spot is None:
        raise RuntimeError("lot full")
    self.occupied_spot_ids.add(spot.id)
    ticket = Ticket(str(uuid.uuid4()), spot.id, vehicle_type, datetime.utcnow())
    self.tickets[ticket.id] = ticket
    return ticket

def exit(self, ticket_id: str, exit_time: Optional[datetime] = None) -> int:
    exit_time = exit_time or datetime.utcnow()
    ticket = self.tickets.pop(ticket_id, None)
    if ticket is None:
        raise RuntimeError("invalid ticket")
    self.occupied_spot_ids.discard(ticket.spot_id)
    return self.compute_fee(ticket.entry_time, exit_time)
Optional<ParkingSpot> findAvailableSpot(VehicleType vehicleType) {
    for (ParkingSpot spot : spots.values()) {
        if (occupiedSpotIds.contains(spot.id)) continue;
        if (spot.fits(vehicleType)) return Optional.of(spot);
    }
    return Optional.empty();
}

int computeFee(Instant entry, Instant exit) {
    long seconds = Math.max(0, Duration.between(entry, exit).getSeconds());
    long hours = Math.max(1, (long) Math.ceil(seconds / 3600.0)); // round up; min 1 hour
    return (int) (hours * hourlyRateCents);
}

Ticket enter(VehicleType vehicleType) {
    ParkingSpot spot = findAvailableSpot(vehicleType)
        .orElseThrow(() -> new IllegalStateException("lot full"));
    occupiedSpotIds.add(spot.id);
    Ticket ticket = new Ticket(UUID.randomUUID().toString(), spot.id, vehicleType, Instant.now());
    tickets.put(ticket.id, ticket);
    return ticket;
}

int exit(String ticketId, Instant exitTime) {
    Ticket ticket = tickets.remove(ticketId);
    if (ticket == null) throw new IllegalStateException("invalid ticket");
    occupiedSpotIds.remove(ticket.spotId);
    return computeFee(ticket.entryTime, exitTime != null ? exitTime : Instant.now());
}

Verification

  • Car enters → compatible free spot marked occupied → ticket returned.
  • Second car when no CAR spots left → enter raises.
  • Exit after 61 minutes at $5/hr → 2 hours → 1000 cents if rate is 500.
  • Exit again with same id → invalid ticket.
  • Happy: Car enters → compatible spot occupied → ticket returned with entry time.
  • Failure: No compatible free spots → enter raises; unknown ticket id on exit → raises; second exit same id → raises.
  • Concurrency: Two threads enter for the last car spot — lock must cover find + occupy + ticket insert so only one wins.

Extensibility

  • Multi-floor — Floor owns spot lists; lot iterates floors (or Strategy: fill lower first / balance).
  • Per-type rates — rates: dict[VehicleType, int] in compute_fee.
  • Concurrent entrances — lock around find + occupy + ticket insert (check-then-act). See correctness.

Common interview pitfalls

These mistakes show up constantly on this prompt. Name the trap, then show the fix in your design — don’t wait for the interviewer to catch you.

  • Modeling Vehicle as a deep class hierarchy when an enum + spot.fits(type) is enough.
  • Storing occupancy as spot.occupied = True without a lot-level occupied set / ticket map — exit becomes O(n) and double-exit bugs hide.
  • Pricing with floats (“$5.00”) instead of integer cents and ceil-to-next-hour rules.
  • Letting callers mutate spots directly instead of enter/exit on ParkingLot.
  • Ignoring the full-lot reject path and the invalid-ticket exit path.
  • Hand-waving concurrency: two entrances can assign the same spot without a lock around find+occupy+ticket.

Interview script (say this)

Read this once out loud before a mock. It’s the spine of a strong answer — not a script to recite robotically.

  1. I’d clarify vehicle types, who assigns spots, ticket proof, fee rules, and what’s out of scope — payments and UI.
  2. v1: motorcycle/car/large, system assigns, hourly rate in cents with round-up, reject when full or ticket invalid.
  3. Entities: ParkingLot orchestrates; ParkingSpot is typed capacity; Ticket is the visit record; VehicleType is an enum.
  4. Lot holds spots, occupied_spot_ids, tickets map, and hourly_rate_cents.
  5. API: enter(vehicle_type) → Ticket; exit(ticket_id) → fee_cents.
  6. enter finds a compatible free spot, marks occupied, creates ticket, returns it — or raises if full.
  7. exit pops the ticket, frees the spot, computes ceil hours times rate.
  8. I’ll trace: park car, park until full, exit after 61 minutes, reuse ticket id.
  9. Extensions: floors via Floor ownership, per-type rates, lock around check-then-act for concurrent gates.

Extra verification traces

Walk these three traces on the board. If you can narrate them cleanly, your implementation section usually follows.

def enter_safe(lot, vehicle_type, lock):
    with lock:
        spot = lot.find_available_spot(vehicle_type)
        if spot is None:
            raise RuntimeError("lot full")
        lot.occupied_spot_ids.add(spot.id)
        ticket = Ticket.new(spot.id, vehicle_type)
        lot.tickets[ticket.id] = ticket
        return ticket
Ticket enterSafe(ParkingLot lot, VehicleType vehicleType, Lock lock) {
    lock.lock();
    try {
        ParkingSpot spot = lot.findAvailableSpot(vehicleType)
            .orElseThrow(() -> new IllegalStateException("lot full"));
        lot.occupiedSpotIds.add(spot.id);
        Ticket ticket = Ticket.newTicket(spot.id, vehicleType);
        lot.tickets.put(ticket.id, ticket);
        return ticket;
    } finally {
        lock.unlock();
    }
}

Staff-level follow-ups

At staff+, they twist the prompt. Answer in one sentence that names the seam — don’t redesign the whole board.

  • Multi-floor lot? — Introduce Floor owning spots; ParkingLot iterates floors or plugs an AllocationStrategy.
  • EV / charging spots? — Add SpotFeature flags; fits() checks type and feature needs without a Vehicle subclass tree.
  • Reservations? — Hold spot with TTL like booking — FREE→HELD→OCCUPIED; expire job releases holds.
  • Different rates by type? — rates: dict[VehicleType, int] inside compute_fee; lot API unchanged.
  • Many entrances? — Single lock on lot mutation first; later stripe locks by floor if contention shows up.

Complete solution (with concurrency)

Full v1 class: typed spots, tickets map, hourly fee in cents, and a single lot lock around enter/exit so two gates cannot claim the same spot.
from dataclasses import dataclass
from datetime import datetime
from enum import Enum, auto
from threading import Lock
from typing import Optional
import math, uuid

class VehicleType(Enum):
    MOTORCYCLE = auto()
    CAR = auto()
    LARGE = auto()

class SpotType(Enum):
    MOTORCYCLE = auto()
    COMPACT = auto()
    LARGE = auto()

COMPAT = {
    VehicleType.MOTORCYCLE: {SpotType.MOTORCYCLE, SpotType.COMPACT, SpotType.LARGE},
    VehicleType.CAR: {SpotType.COMPACT, SpotType.LARGE},
    VehicleType.LARGE: {SpotType.LARGE},
}

@dataclass
class ParkingSpot:
    id: str
    spot_type: SpotType
    def fits(self, v: VehicleType) -> bool:
        return self.spot_type in COMPAT[v]

@dataclass
class Ticket:
    id: str
    spot_id: str
    vehicle_type: VehicleType
    entry_time: datetime

class ParkingLot:
    def __init__(self, spots: list[ParkingSpot], hourly_rate_cents: int):
        self.spots = {s.id: s for s in spots}
        self.occupied: set[str] = set()
        self.tickets: dict[str, Ticket] = {}
        self.hourly_rate_cents = hourly_rate_cents
        self._lock = Lock()

    def enter(self, vehicle_type: VehicleType) -> Ticket:
        with self._lock:
            spot = next(
                (s for s in self.spots.values()
                 if s.id not in self.occupied and s.fits(vehicle_type)),
                None,
            )
            if spot is None:
                raise RuntimeError("lot full")
            self.occupied.add(spot.id)
            t = Ticket(str(uuid.uuid4()), spot.id, vehicle_type, datetime.utcnow())
            self.tickets[t.id] = t
            return t

    def exit(self, ticket_id: str, exit_time: Optional[datetime] = None) -> int:
        exit_time = exit_time or datetime.utcnow()
        with self._lock:
            t = self.tickets.pop(ticket_id, None)
            if t is None:
                raise RuntimeError("invalid ticket")
            self.occupied.discard(t.spot_id)
            secs = max(0, (exit_time - t.entry_time).total_seconds())
            hours = max(1, math.ceil(secs / 3600))
            return hours * self.hourly_rate_cents
import java.time.*;
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

enum VehicleType { MOTORCYCLE, CAR, LARGE }
enum SpotType { MOTORCYCLE, COMPACT, LARGE }

class ParkingSpot {
    final String id; final SpotType type;
    ParkingSpot(String id, SpotType type) { this.id = id; this.type = type; }
    boolean fits(VehicleType v) {
        return switch (v) {
            case MOTORCYCLE -> true;
            case CAR -> type == SpotType.COMPACT || type == SpotType.LARGE;
            case LARGE -> type == SpotType.LARGE;
        };
    }
}

class Ticket {
    final String id, spotId; final VehicleType vehicleType; final Instant entryTime;
    Ticket(String id, String spotId, VehicleType vt, Instant t) {
        this.id = id; this.spotId = spotId; this.vehicleType = vt; this.entryTime = t;
    }
}

class ParkingLot {
    private final Map<String, ParkingSpot> spots = new HashMap<>();
    private final Set<String> occupied = new HashSet<>();
    private final Map<String, Ticket> tickets = new HashMap<>();
    private final int hourlyRateCents;
    private final ReentrantLock lock = new ReentrantLock();

    ParkingLot(List<ParkingSpot> list, int hourlyRateCents) {
        for (ParkingSpot s : list) spots.put(s.id, s);
        this.hourlyRateCents = hourlyRateCents;
    }

    Ticket enter(VehicleType vehicleType) {
        lock.lock();
        try {
            ParkingSpot spot = null;
            for (ParkingSpot s : spots.values()) {
                if (!occupied.contains(s.id) && s.fits(vehicleType)) { spot = s; break; }
            }
            if (spot == null) throw new IllegalStateException("lot full");
            occupied.add(spot.id);
            Ticket t = new Ticket(UUID.randomUUID().toString(), spot.id, vehicleType, Instant.now());
            tickets.put(t.id, t);
            return t;
        } finally { lock.unlock(); }
    }

    int exit(String ticketId, Instant exitTime) {
        if (exitTime == null) exitTime = Instant.now();
        lock.lock();
        try {
            Ticket t = tickets.remove(ticketId);
            if (t == null) throw new IllegalStateException("invalid ticket");
            occupied.remove(t.spotId);
            long secs = Math.max(0, Duration.between(t.entryTime, exitTime).getSeconds());
            long hours = Math.max(1, (long) Math.ceil(secs / 3600.0));
            return (int) (hours * hourlyRateCents);
        } finally { lock.unlock(); }
    }
}

Concurrency cases

  • Last spot race — two enter() calls; without lock both see free → double occupy. With lock, second raises lot full.
  • Enter vs exit — exit frees a spot while enter scans; lock serializes so enter either sees the free spot or not — never a torn occupied set.
  • Double exit — second exit pops missing ticket → invalid; lock not strictly required for correctness of pop if dict ops are atomic, but keep exit under the same lock so occupied/tickets stay paired.
# Timeline (unlocked — BROKEN)
# T1 find spot S free          T2 find spot S free
# T1 occupied.add(S)           T2 occupied.add(S)
# T1 ticket A                  T2 ticket B
# → two tickets, one spot

# Locked — T2 blocks until T1 finishes; T2 sees S occupied.
// Timeline (unlocked — BROKEN)
// T1 find spot S free          T2 find spot S free
// T1 occupied.add(S)           T2 occupied.add(S)
// T1 ticket A                  T2 ticket B
// → two tickets, one spot

// Locked — T2 blocks until T1 finishes; T2 sees S occupied.

← Lattice