Skip to content

Design a parking lot

TL;DR

  • You build a multi-floor lot where gates issue tickets, spots are allocated by a pluggable policy, and exits charge by a pluggable pricing rule.
  • Three decisions carry the interview: who owns the lock (each floor, not the lot), how the exit gate avoids double charging (reserve the ticket, charge, commit), and how vehicles map to spots (polymorphism, not an if/elif ladder).
  • Patterns that earn their place: Strategy (pricing, allocation), Factory Method (vehicles), Observer (display board). Singleton is discussed and deliberately not used.

Problem statement

"Design the software for a multi-floor parking lot. Vehicles of different sizes arrive at several entry gates and receive a ticket; when they leave through an exit gate they pay according to how long they stayed. The lot should display free spots per floor and reject vehicles when it is full. Focus on the core classes, the flows, and how it behaves when two gates act at the same time."

Requirements

Functional

  • Multiple floors; each floor has spots of types motorcycle, compact, large and electric (compact with a charger).
  • Vehicle types: motorcycle, car, truck, electric car. A motorcycle may use a motorcycle, compact or large spot (never an electric one — a charger is not wasted on it); a car a compact or large spot; a truck only a large spot; an electric car prefers an electric spot but falls back to a compact or large one.
  • Several entry gates issue tickets and assign the nearest eligible spot (lowest floor, most suitable type, lowest spot number).
  • Several exit gates compute the fee with a pricing strategy (hourly with a grace period, flat rate, hourly with a daily cap), accept cash or card, and free the spot.
  • A lost ticket is settled with a flat penalty by licence plate.
  • A display board per floor shows free spots per type; admins can take spots out of service.
  • When no eligible spot is free anywhere, the gate rejects the vehicle.

Non-functional and constraints

  • Correct under concurrency: two gates must never assign the same spot, and two exit gates must never charge the same ticket.
  • In-memory, single process; persistence is behind an interface you could add later.
  • Deterministic and testable: time and IDs are injected.

Out of scope: payments integration beyond a processor interface, reservations, pricing by demand, hardware (barriers, cameras).

Clarifying questions and assumptions

Question to ask Assumption taken here
How many gates, and can they act simultaneously? Yes; that is the whole point of the concurrency section.
Is "nearest" measured from the gate or just the lowest floor? Lowest floor, then spot number. The allocation strategy is pluggable, so per-gate distance is a one-class change.
Can a small vehicle take a bigger spot? Yes, in order of preference; a truck never fits a compact spot.
How is the fee computed? Per started hour after a 15-minute grace period by default; the strategy is injectable.
Do we charge for a declined card? No: a declined payment leaves the car parked and the ticket active.
Do we need persistence or multiple lots? Not now; the lot is an ordinary object, so a second lot is a second instance.

Core entities and relationships

  • Vehicle (abstract) with Motorcycle, Car, Truck, ElectricCar. Each subclass declares the spot types it accepts, most preferred first. VehicleFactory turns the gate's raw input into the right subclass.
  • ParkingSpot — id, floor, SpotType, SpotStatus (free / occupied / out of service) and the vehicle currently in it. One spot belongs to exactly one floor.
  • ParkingFloor — owns many spots and the lock that makes assignment on that floor atomic. It publishes FloorAvailability to listeners.
  • ParkingLot — the aggregate root: many floors, the ticket registry, the allocation strategy, the injected clock and ID generator. Built once in main and passed to the gates.
  • Ticket — plate, vehicle type, spot, entry time, and a status that moves ACTIVE → PAYING → PAID (or LOST).
  • EntryGate / ExitGate — thin services: the entry gate creates the vehicle and asks the lot to park it; the exit gate prices the stay, charges through a PaymentProcessor, and commits.
  • PricingStrategy and SpotAllocationStrategy — the two policies that vary; DisplayBoard — an observer of floor availability.

Multiplicities: lot 1 → * floors, floor 1 → * spots, ticket 1 → 1 spot, lot 1 → * tickets.

Class diagram

Structure: what the lot is made of.

classDiagram
    class Vehicle {
        <<abstract>>
        +plate: str
        +vehicle_type VehicleType
        +allowed_spot_types tuple
    }
    class Motorcycle
    class Car
    class Truck
    class ElectricCar
    class VehicleFactory {
        +create(vehicle_type, plate) Vehicle
    }
    class ParkingSpot {
        +id: str
        +floor: int
        +type: SpotType
        +status: SpotStatus
        +assign(vehicle)
        +release()
    }
    class ParkingFloor {
        +number: int
        -_lock: Lock
        +first_free(spot_type) ParkingSpot
        +try_assign(spot_id, vehicle) bool
        +release(spot_id)
        +availability() FloorAvailability
    }
    class ParkingLot {
        +name: str
        -_tickets_lock: Lock
        +park(vehicle) Ticket
        +begin_checkout(ticket_id) Ticket
        +complete_checkout(ticket, exit_time, fee, status)
        +availability() List~FloorAvailability~
    }
    class Ticket {
        +id: str
        +plate: str
        +spot_id: str
        +entry_time: float
        +status: TicketStatus
        +duration_seconds(now) float
    }
    Vehicle <|-- Motorcycle
    Vehicle <|-- Car
    Vehicle <|-- Truck
    Vehicle <|-- ElectricCar
    VehicleFactory ..> Vehicle : creates
    ParkingLot "1" *-- "many" ParkingFloor
    ParkingFloor "1" *-- "many" ParkingSpot
    ParkingLot "1" o-- "many" Ticket
    ParkingSpot --> "0..1" Vehicle

Behaviour: the gates and the three pluggable policies.

classDiagram
    class EntryGate {
        +issue_ticket(vehicle_type, plate) Ticket
    }
    class ExitGate {
        +quote(ticket_id) Money
        +process(ticket_id, method) Payment
        +process_lost_ticket(plate, method) Payment
    }
    class PricingStrategy {
        <<interface>>
        +calculate(vehicle_type, duration_seconds) Money
    }
    class HourlyPricing
    class FlatRatePricing
    class DailyCapPricing
    class SpotAllocationStrategy {
        <<interface>>
        +choose(floors, vehicle) ParkingSpot
    }
    class NearestFirstAllocation
    class PaymentProcessor {
        <<interface>>
        +charge(amount, method) bool
    }
    class AvailabilityListener {
        <<interface>>
        +on_availability_changed(availability)
    }
    class DisplayBoard {
        +free_spots(floor) int
        +render() str
    }
    class ParkingLot
    class ParkingFloor
    EntryGate --> ParkingLot
    ExitGate --> ParkingLot
    ExitGate --> PricingStrategy
    ExitGate --> PaymentProcessor
    ParkingLot --> SpotAllocationStrategy
    PricingStrategy <|.. HourlyPricing
    PricingStrategy <|.. FlatRatePricing
    PricingStrategy <|.. DailyCapPricing
    SpotAllocationStrategy <|.. NearestFirstAllocation
    AvailabilityListener <|.. DisplayBoard
    ParkingFloor --> "many" AvailabilityListener : notifies

Design patterns applied

Pattern Where Why it earns its place
Strategy PricingStrategy, SpotAllocationStrategy Pricing and allocation are the two rules the interviewer will ask you to change ("now add a daily cap", "now prefer spots near the gate"). Each is a one-class addition.
Factory Method VehicleFactory.create The gate receives a string and a plate; the registry dict maps it to a class. Adding a bus touches the registry, not the gate.
Polymorphism over conditionals Vehicle.allowed_spot_types The allocator never asks "what type are you?" — it iterates the vehicle's own preference list. This is the open/closed principle in one line.
Observer ParkingFloor → AvailabilityListener (DisplayBoard) Floors push availability; the board never polls and floors never know what a board is.
Dependency Injection Clock, IdGenerator, PaymentProcessor, strategies Tests use FakeClock and a declining processor; nothing calls time.time().
State (lightweight) TicketStatus, SpotStatus with guarded transitions Enum plus guard clauses is enough here; full State classes would be ceremony.

What was deliberately not used: Singleton for ParkingLot. Interviewers often expect it; the better answer is "one instance created in main and injected" — tests can build many lots, and a second physical lot becomes a second object instead of a redesign. Say that out loud; it signals judgement.

Key flows

Entry: the gate asks the lot, the lot asks the strategy, the floor does the atomic claim.

sequenceDiagram
    autonumber
    actor D as Driver
    participant G as EntryGate
    participant L as ParkingLot
    participant S as NearestFirstAllocation
    participant F as ParkingFloor
    participant B as DisplayBoard
    D->>G: issue_ticket("car", "KA01AB1234")
    G->>G: VehicleFactory.create
    G->>L: park(vehicle)
    loop until claimed or no candidate
        L->>S: choose(floors, vehicle)
        S-->>L: candidate spot (read without lock)
        L->>F: try_assign(spot_id, vehicle)
        alt spot still free
            F->>F: lock, assign, unlock
            F-)B: on_availability_changed
            F-->>L: True
        else another gate won the race
            F-->>L: False
        end
    end
    L-->>G: Ticket(ACTIVE)
    G-->>D: ticket

Exit: reserve the ticket, price it, charge, commit — so a declined card cannot free the spot.

sequenceDiagram
    autonumber
    actor D as Driver
    participant X as ExitGate
    participant L as ParkingLot
    participant P as PricingStrategy
    participant C as PaymentProcessor
    participant F as ParkingFloor
    D->>X: process(ticket_id, CARD)
    X->>L: begin_checkout(ticket_id)
    L-->>X: ticket (ACTIVE -> PAYING)
    X->>P: calculate(vehicle_type, duration)
    P-->>X: fee
    X->>C: charge(fee, CARD)
    alt approved
        C-->>X: True
        X->>L: complete_checkout(ticket, now, fee, PAID)
        L->>F: release(spot_id)
        X-->>D: Payment
    else declined
        C-->>X: False
        X->>L: abort_checkout(ticket) (PAYING -> ACTIVE)
        X-->>D: PaymentDeclinedError
    end

Ticket lifecycle. The PAYING state is what makes two exit gates safe: only one of them can move the ticket out of ACTIVE.

stateDiagram-v2
    [*] --> ACTIVE : entry gate issues ticket
    ACTIVE --> PAYING : begin_checkout
    PAYING --> ACTIVE : payment declined
    PAYING --> PAID : complete_checkout
    PAYING --> LOST : lost-ticket penalty paid
    PAID --> [*]
    LOST --> [*]

Implementation

Write the code in the order an interviewer wants to see it: the vocabulary first (enums and errors), then the entities, then the policies, then the services that tie them together. Every block below is the real file the tests run.

The enums pin down the vocabulary, and the errors subclass the shared hierarchy so callers can catch ConflictError without knowing about parking:

code/lld/parking_lot/models.py — enums
class VehicleType(StrEnum):
    MOTORCYCLE = "motorcycle"
    CAR = "car"
    TRUCK = "truck"
    ELECTRIC_CAR = "electric_car"


class SpotType(StrEnum):
    MOTORCYCLE = "motorcycle"
    COMPACT = "compact"
    LARGE = "large"
    ELECTRIC = "electric"  # compact spot with a charger


class SpotStatus(StrEnum):
    FREE = "free"
    OCCUPIED = "occupied"
    OUT_OF_SERVICE = "out_of_service"


class TicketStatus(StrEnum):
    ACTIVE = "active"  # vehicle is inside
    PAYING = "paying"  # reserved by an exit gate while the payment is in flight
    PAID = "paid"  # fee settled at the exit gate, spot released
    LOST = "lost"  # settled via the lost-ticket flow


class PaymentMethod(StrEnum):
    CASH = "cash"
    CARD = "card"
code/lld/parking_lot/models.py — errors
class LotFullError(ConflictError):
    """No spot of an acceptable type is free anywhere in the lot."""


class InvalidTicketError(NotFoundError):
    """Unknown ticket id (or plate, for the lost-ticket flow)."""


class TicketStateError(InvalidStateError):
    """The ticket is not in a state that allows the operation (e.g. paid twice)."""


class PaymentDeclinedError(ConflictError):
    """The payment processor refused the charge; the ticket stays active."""

Vehicles carry their own spot preferences. Note that allowed_spot_types is ordered: the allocator tries a motorcycle spot before a compact one for a motorcycle, so cheap spots are not wasted.

code/lld/parking_lot/models.py — vehicles
class Vehicle(ABC):
    """A vehicle knows which spot types can hold it, in order of preference.

    Polymorphism replaces an if/elif chain on vehicle type in the allocator:
    adding a new vehicle class never touches the lot code (open/closed).
    """

    def __init__(self, plate: str) -> None:
        if not plate or not plate.strip():
            raise ValidationError("plate must be non-empty")
        self.plate = plate.strip().upper()

    @property
    @abstractmethod
    def vehicle_type(self) -> VehicleType: ...

    @property
    @abstractmethod
    def allowed_spot_types(self) -> tuple[SpotType, ...]:
        """Acceptable spot types, most preferred first."""

    def __repr__(self) -> str:
        return f"{type(self).__name__}({self.plate!r})"


class Motorcycle(Vehicle):
    vehicle_type = VehicleType.MOTORCYCLE
    allowed_spot_types = (SpotType.MOTORCYCLE, SpotType.COMPACT, SpotType.LARGE)


class Car(Vehicle):
    vehicle_type = VehicleType.CAR
    allowed_spot_types = (SpotType.COMPACT, SpotType.LARGE)


class Truck(Vehicle):
    vehicle_type = VehicleType.TRUCK
    allowed_spot_types = (SpotType.LARGE,)


class ElectricCar(Vehicle):
    vehicle_type = VehicleType.ELECTRIC_CAR
    allowed_spot_types = (SpotType.ELECTRIC, SpotType.COMPACT, SpotType.LARGE)


class VehicleFactory:
    """Factory Method: the gate turns raw input ("car", "KA01AB1234") into an object."""

    _registry: dict[VehicleType, type[Vehicle]] = {
        VehicleType.MOTORCYCLE: Motorcycle,
        VehicleType.CAR: Car,
        VehicleType.TRUCK: Truck,
        VehicleType.ELECTRIC_CAR: ElectricCar,
    }

    @classmethod
    def create(cls, vehicle_type: VehicleType | str, plate: str) -> Vehicle:
        try:
            klass = cls._registry[VehicleType(vehicle_type)]
        except ValueError as exc:
            raise ValidationError(f"unknown vehicle type: {vehicle_type!r}") from exc
        return klass(plate)

Spots and tickets are plain dataclasses with guarded transitions; FloorAvailability is the immutable message floors publish to observers.

code/lld/parking_lot/models.py — entities
@dataclass(slots=True)
class ParkingSpot:
    id: str  # e.g. "F1-C07"
    floor: int
    type: SpotType
    status: SpotStatus = SpotStatus.FREE
    vehicle: Vehicle | None = None

    def is_free(self) -> bool:
        return self.status is SpotStatus.FREE

    def assign(self, vehicle: Vehicle) -> None:
        if not self.is_free():
            raise ConflictError(f"spot {self.id} is {self.status}")
        self.vehicle = vehicle
        self.status = SpotStatus.OCCUPIED

    def release(self) -> None:
        if self.status is not SpotStatus.OCCUPIED:
            raise InvalidStateError(f"spot {self.id} is not occupied")
        self.vehicle = None
        self.status = SpotStatus.FREE


@dataclass(slots=True)
class Ticket:
    id: str
    plate: str
    vehicle_type: VehicleType
    spot_id: str
    floor: int
    entry_time: float  # epoch seconds, from the injected Clock
    exit_time: float | None = None
    fee: Money | None = None
    status: TicketStatus = TicketStatus.ACTIVE

    def duration_seconds(self, now: float) -> float:
        return max(0.0, (self.exit_time or now) - self.entry_time)


@dataclass(frozen=True, slots=True)
class Payment:
    id: str
    ticket_id: str
    amount: Money
    method: PaymentMethod
    paid_at: float


@dataclass(frozen=True, slots=True)
class FloorAvailability:
    """What a display board shows for one floor: free spots per type."""

    floor: int
    free: dict[SpotType, int] = field(default_factory=dict)

    def total_free(self) -> int:
        return sum(self.free.values())

Pricing is a Protocol, so any object with a calculate method qualifies — no inheritance needed. DailyCapPricing composes HourlyPricing rather than subclassing it.

code/lld/parking_lot/strategies.py — pricing
class PricingStrategy(Protocol):
    """Turns a stay into money. Implementations are stateless and thread-safe."""

    def calculate(self, vehicle_type: VehicleType, duration_seconds: float) -> Money: ...


DEFAULT_FLAT_RATE = Money.of("10.00")
DEFAULT_DAILY_CAP = Money.of("20.00")
DEFAULT_HOURLY_RATES: dict[VehicleType, Money] = {
    VehicleType.MOTORCYCLE: Money.of("1.00"),
    VehicleType.CAR: Money.of("3.00"),
    VehicleType.ELECTRIC_CAR: Money.of("3.50"),
    VehicleType.TRUCK: Money.of("6.00"),
}


class HourlyPricing:
    """Charge per started hour; a free grace period (default 15 min) costs nothing."""

    def __init__(
        self,
        rates: dict[VehicleType, Money] | None = None,
        grace_seconds: int = 15 * 60,
    ) -> None:
        self._rates = rates or DEFAULT_HOURLY_RATES
        self._grace = grace_seconds

    def calculate(self, vehicle_type: VehicleType, duration_seconds: float) -> Money:
        if duration_seconds < 0:
            raise ValidationError("duration cannot be negative")
        if duration_seconds <= self._grace:
            return Money(0)
        hours = math.ceil(duration_seconds / 3600)
        return self._rates[vehicle_type] * hours


class FlatRatePricing:
    """One price per visit regardless of duration (event parking)."""

    def __init__(self, rate: Money = DEFAULT_FLAT_RATE) -> None:
        self._rate = rate

    def calculate(self, vehicle_type: VehicleType, duration_seconds: float) -> Money:
        return self._rate


class DailyCapPricing:
    """Hourly pricing, but no day costs more than the cap (airport parking)."""

    def __init__(self, hourly: HourlyPricing, daily_cap: Money = DEFAULT_DAILY_CAP) -> None:
        self._hourly = hourly
        self._cap = daily_cap

    def calculate(self, vehicle_type: VehicleType, duration_seconds: float) -> Money:
        full_days, remainder = divmod(duration_seconds, 86_400)
        remainder_fee = self._hourly.calculate(vehicle_type, remainder)
        capped_remainder = min(remainder_fee, self._cap)
        return self._cap * int(full_days) + capped_remainder

Allocation only selects; it never mutates. That separation is what lets the floor own the lock.

code/lld/parking_lot/strategies.py — allocation
class SpotAllocationStrategy(Protocol):
    """Chooses a spot for a vehicle. Must only *select*; the floor does the locking."""

    def choose(self, floors: list[ParkingFloor], vehicle: Vehicle) -> ParkingSpot | None: ...


class NearestFirstAllocation:
    """Lowest floor first, then the vehicle's most preferred spot type, then lowest spot id.

    A motorcycle therefore takes a motorcycle spot before it falls back to a
    compact one — cheap spots are not wasted on small vehicles.
    """

    def choose(self, floors: list[ParkingFloor], vehicle: Vehicle) -> ParkingSpot | None:
        for floor in sorted(floors, key=lambda f: f.number):
            for spot_type in vehicle.allowed_spot_types:
                spot = floor.first_free(spot_type)
                if spot is not None:
                    return spot
        return None


class ElectricFirstAllocation(NearestFirstAllocation):
    """Same as nearest-first, but never gives an electric spot to a non-electric vehicle."""

    def choose(self, floors: list[ParkingFloor], vehicle: Vehicle) -> ParkingSpot | None:
        if SpotType.ELECTRIC in vehicle.allowed_spot_types[:1]:
            return super().choose(floors, vehicle)
        for floor in sorted(floors, key=lambda f: f.number):
            for spot_type in vehicle.allowed_spot_types:
                if spot_type is SpotType.ELECTRIC:
                    continue
                spot = floor.first_free(spot_type)
                if spot is not None:
                    return spot
        return None

The floor is the unit of locking. first_free is a lock-free hint; try_assign re-checks under the lock and returns False if another gate got there first.

code/lld/parking_lot/services.py — floor
class ParkingFloor:
    """Owns its spots and the lock that makes assignment atomic *per floor*.

    Two gates racing for the last spot on this floor serialise here; gates
    working on different floors never contend with each other.
    """

    def __init__(self, number: int, spots: Iterable[ParkingSpot]) -> None:
        self.number = number
        self._spots: dict[str, ParkingSpot] = {s.id: s for s in spots}
        self._lock = threading.Lock()
        self._listeners: list[AvailabilityListener] = []

    @classmethod
    def build(cls, number: int, layout: dict[SpotType, int]) -> ParkingFloor:
        """``ParkingFloor.build(1, {SpotType.COMPACT: 3, SpotType.LARGE: 1})``."""
        spots = [
            ParkingSpot(id=f"F{number}-{SPOT_PREFIX[spot_type]}{i:02d}", floor=number, type=spot_type)
            for spot_type, count in layout.items()
            for i in range(1, count + 1)
        ]
        return cls(number, spots)

    def subscribe(self, listener: AvailabilityListener) -> None:
        self._listeners.append(listener)
        listener.on_availability_changed(self.availability())

    def spot(self, spot_id: str) -> ParkingSpot:
        return self._spots[spot_id]

    def first_free(self, spot_type: SpotType) -> ParkingSpot | None:
        """A *hint* read without the lock; ``try_assign`` re-checks under the lock."""
        for spot in self._spots.values():  # insertion order == lowest id first
            if spot.type is spot_type and spot.is_free():
                return spot
        return None

    def try_assign(self, spot_id: str, vehicle: Vehicle) -> bool:
        """Claim the spot atomically. Returns False if someone else got it first."""
        with self._lock:
            spot = self._spots[spot_id]
            if not spot.is_free():
                return False
            spot.assign(vehicle)
            availability = self.availability()
        self._notify(availability)
        return True

    def release(self, spot_id: str) -> None:
        with self._lock:
            self._spots[spot_id].release()
            availability = self.availability()
        self._notify(availability)

    def set_out_of_service(self, spot_id: str) -> None:
        with self._lock:
            spot = self._spots[spot_id]
            if spot.status is SpotStatus.OCCUPIED:
                raise TicketStateError(f"spot {spot_id} is occupied")
            spot.status = SpotStatus.OUT_OF_SERVICE
            availability = self.availability()
        self._notify(availability)

    def availability(self) -> FloorAvailability:
        free: dict[SpotType, int] = {}
        for spot in self._spots.values():
            free.setdefault(spot.type, 0)
            if spot.is_free():
                free[spot.type] += 1
        return FloorAvailability(self.number, free)

    def _notify(self, availability: FloorAvailability) -> None:
        # Outside the lock: a slow listener must never block a gate.
        for listener in self._listeners:
            listener.on_availability_changed(availability)

The lot runs an optimistic loop: choose, try to claim, and retry on a lost race. The ticket registry has its own lock, and the begin / complete / abort trio is the checkout state machine.

code/lld/parking_lot/services.py — lot
class ParkingLot:
    """The aggregate root: floors + tickets. Built once in ``main`` and injected into gates.

    Deliberately *not* a Singleton class: tests build many lots, and a second
    physical lot is then one more object rather than a redesign.
    """

    MAX_CLAIM_ATTEMPTS = 64

    def __init__(
        self,
        name: str,
        floors: Iterable[ParkingFloor],
        allocation: SpotAllocationStrategy | None = None,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
    ) -> None:
        self.name = name
        self._floors = {f.number: f for f in floors}
        self._allocation = allocation or NearestFirstAllocation()
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("T")
        self._tickets: dict[str, Ticket] = {}
        self._tickets_lock = threading.Lock()

    def floors(self) -> list[ParkingFloor]:
        return list(self._floors.values())

    def park(self, vehicle: Vehicle) -> Ticket:
        """Choose a spot optimistically, then claim it atomically; retry if a gate beat us to it."""
        for _ in range(self.MAX_CLAIM_ATTEMPTS):
            spot = self._allocation.choose(self.floors(), vehicle)
            if spot is None:
                raise LotFullError(f"no free spot for {vehicle!r} in {self.name}")
            if self._floors[spot.floor].try_assign(spot.id, vehicle):
                ticket = Ticket(
                    id=self._ids.next_id(),
                    plate=vehicle.plate,
                    vehicle_type=vehicle.vehicle_type,
                    spot_id=spot.id,
                    floor=spot.floor,
                    entry_time=self._clock.now(),
                )
                with self._tickets_lock:
                    self._tickets[ticket.id] = ticket
                return ticket
        raise LotFullError("could not claim a spot under heavy contention; try again")

    def ticket(self, ticket_id: str) -> Ticket:
        with self._tickets_lock:
            try:
                return self._tickets[ticket_id]
            except KeyError:
                raise InvalidTicketError(f"unknown ticket {ticket_id}") from None

    def active_ticket_for_plate(self, plate: str) -> Ticket | None:
        plate = plate.strip().upper()
        with self._tickets_lock:
            for ticket in self._tickets.values():
                if ticket.plate == plate and ticket.status is TicketStatus.ACTIVE:
                    return ticket
        return None

    def begin_checkout(self, ticket_id: str) -> Ticket:
        """ACTIVE -> PAYING. Two exit gates cannot both charge the same ticket."""
        with self._tickets_lock:
            ticket = self._tickets.get(ticket_id)
            if ticket is None:
                raise InvalidTicketError(f"unknown ticket {ticket_id}")
            if ticket.status is not TicketStatus.ACTIVE:
                raise TicketStateError(f"ticket {ticket_id} is {ticket.status}, not active")
            ticket.status = TicketStatus.PAYING
            return ticket

    def abort_checkout(self, ticket: Ticket) -> None:
        with self._tickets_lock:
            ticket.status = TicketStatus.ACTIVE

    def complete_checkout(self, ticket: Ticket, exit_time: float, fee: Money, status: TicketStatus) -> None:
        """PAYING -> PAID/LOST and free the spot."""
        with self._tickets_lock:
            if ticket.status is not TicketStatus.PAYING:
                raise TicketStateError(f"ticket {ticket.id} is {ticket.status}, not paying")
            ticket.exit_time = exit_time
            ticket.fee = fee
            ticket.status = status
        self._floors[ticket.floor].release(ticket.spot_id)

    def availability(self) -> list[FloorAvailability]:
        return [f.availability() for f in self.floors()]

    def free_spots(self) -> int:
        return sum(a.total_free() for a in self.availability())

The gates are thin. The exit gate's _settle is the one place where "charge, then commit" is enforced.

code/lld/parking_lot/services.py — gates
class EntryGate:
    def __init__(self, gate_id: str, lot: ParkingLot) -> None:
        self.gate_id = gate_id
        self._lot = lot

    def issue_ticket(self, vehicle_type: VehicleType | str, plate: str) -> Ticket:
        vehicle = VehicleFactory.create(vehicle_type, plate)
        return self._lot.park(vehicle)


class PaymentProcessor(Protocol):
    def charge(self, amount: Money, method: PaymentMethod) -> bool: ...


class AlwaysApprovesProcessor:
    """Stand-in for the card terminal / cash drawer."""

    def charge(self, amount: Money, method: PaymentMethod) -> bool:
        return True


class ExitGate:
    LOST_TICKET_FEE = Money.of("50.00")

    def __init__(
        self,
        gate_id: str,
        lot: ParkingLot,
        pricing: PricingStrategy | None = None,
        processor: PaymentProcessor | None = None,
        clock: Clock | None = None,
        ids: IdGenerator | None = None,
    ) -> None:
        self.gate_id = gate_id
        self._lot = lot
        self._pricing = pricing or HourlyPricing()
        self._processor = processor or AlwaysApprovesProcessor()
        self._clock = clock or SystemClock()
        self._ids = ids or SequentialIdGenerator("P")

    def quote(self, ticket_id: str) -> Money:
        ticket = self._lot.ticket(ticket_id)
        return self._pricing.calculate(ticket.vehicle_type, ticket.duration_seconds(self._clock.now()))

    def process(self, ticket_id: str, method: PaymentMethod) -> Payment:
        """Reserve the ticket, charge, then commit — so a declined card leaves the car parked."""
        ticket = self._lot.begin_checkout(ticket_id)
        now = self._clock.now()
        fee = self._pricing.calculate(ticket.vehicle_type, ticket.duration_seconds(now))
        return self._settle(ticket, fee, method, now, TicketStatus.PAID)

    def process_lost_ticket(self, plate: str, method: PaymentMethod) -> Payment:
        ticket = self._lot.active_ticket_for_plate(plate)
        if ticket is None:
            raise InvalidTicketError(f"no active ticket for plate {plate}")
        self._lot.begin_checkout(ticket.id)
        return self._settle(ticket, self.LOST_TICKET_FEE, method, self._clock.now(), TicketStatus.LOST)

    def _settle(
        self, ticket: Ticket, fee: Money, method: PaymentMethod, now: float, status: TicketStatus
    ) -> Payment:
        if not fee.is_zero() and not self._processor.charge(fee, method):
            self._lot.abort_checkout(ticket)
            raise PaymentDeclinedError(f"{method} payment of {fee} declined for ticket {ticket.id}")
        self._lot.complete_checkout(ticket, exit_time=now, fee=fee, status=status)
        return Payment(id=self._ids.next_id(), ticket_id=ticket.id, amount=fee, method=method, paid_at=now)

The observer side is small and deliberately decoupled — the floor notifies outside its lock so a slow listener can never block a gate:

code/lld/parking_lot/services.py — observer
class AvailabilityListener(Protocol):
    """Observer interface: anything that wants to know when a floor's free counts change."""

    def on_availability_changed(self, availability: FloorAvailability) -> None: ...


class DisplayBoard:
    """The sign at the entrance. It never polls; floors push updates to it."""

    def __init__(self) -> None:
        self._lock = threading.Lock()
        self._floors: dict[int, FloorAvailability] = {}

    def on_availability_changed(self, availability: FloorAvailability) -> None:
        with self._lock:
            self._floors[availability.floor] = availability

    def free_spots(self, floor: int) -> int:
        with self._lock:
            return self._floors[floor].total_free() if floor in self._floors else 0

    def render(self) -> str:
        with self._lock:
            lines = []
            for number in sorted(self._floors):
                counts = ", ".join(
                    f"{n} {spot_type.value}" for spot_type, n in self._floors[number].free.items()
                )
                lines.append(f"Floor {number}: {counts}")
            return "\n".join(lines)

Running python -m lld.parking_lot.demo prints the scenario end to end:

T-1:          car KA01AB1234 -> spot F1-C01
T-2:   motorcycle KA02MC0001 -> spot F1-M01
T-3:        truck KA03TR9999 -> spot F1-L01
T-4: electric_car KA04EV4242 -> spot F1-E01
--- display board ---
Floor 1: 1 motorcycle, 2 compact, 0 large, 0 electric
Floor 2: 4 compact, 2 large
--- KA01AB1234 leaves after 2h35m: charged 9.00 USD (card) ---
second exit rejected: ticket T-1 is paid, not active
lost ticket for KA02MC0001: charged 50.00 USD
--- display board ---
Floor 1: 2 motorcycle, 3 compact, 0 large, 0 electric
Floor 2: 4 compact, 2 large
free spots in Downtown: 11

Concurrency and edge cases

Which lock protects what. There are exactly two kinds of lock, and naming them is the answer the interviewer is fishing for:

  1. ParkingFloor._lock guards spot status on that floor. Two gates racing for the last compact spot on floor 1 serialise on it; a gate working on floor 2 is not affected. This is finer than one lot-wide lock and coarser than one lock per spot (which would make "find a free spot" a multi-lock dance).
  2. ParkingLot._tickets_lock guards the ticket registry and every ticket status transition. begin_checkout moves a ticket ACTIVE → PAYING under this lock, so two exit gates handed the same ticket cannot both charge it; the loser gets TicketStateError.

The optimistic claim loop. The allocation strategy reads spot status without a lock (a hint), and try_assign re-validates under the floor lock. If the spot was taken in between, park simply chooses again. The loop is bounded (MAX_CLAIM_ATTEMPTS) so a pathological storm degrades to a clean LotFullError instead of a spin.

Declined payment. The charge happens while the ticket is PAYING; on failure abort_checkout returns it to ACTIVE and the spot stays occupied. Nothing is released before money is confirmed.

Edge cases handled: grace period (first 15 minutes free), partial hours rounded up, lost ticket by plate (only an active ticket qualifies), out-of-service spots excluded from allocation, unknown ticket ids, a second exit on a paid ticket, empty plates, unknown vehicle types.

Common mistake

Putting a single lock on ParkingLot.park and calling the problem solved. It is correct, but it serialises every gate in the building through one mutex, and you have just told the interviewer you did not think about granularity. Name the floor lock, explain why not per-spot, and mention the optimistic retry.

Extensibility and follow-ups

  • Reservations: add a RESERVED spot status with an expiry and a ReservationService that holds the floor lock when converting a reservation into an assignment; allocation skips reserved spots.
  • EV charging fees: a ChargingPricing decorator around any PricingStrategy that adds energy cost from a meter reading — Decorator, not a subclass explosion.
  • Per-gate "nearest": a NearestToGateAllocation(gate_location) strategy that sorts spots by distance; nothing else changes.
  • Persistence: ParkingLot keeps tickets in a dict today; put that behind a TicketRepository protocol with an in-memory and a SQL implementation.
  • Multiple lots / a city-wide system: this is where the conversation becomes an HLD question — availability APIs, a search service, payments, and eventual consistency of the display boards.
  • Hardware events: barrier and camera integrations become additional AvailabilityListeners or gate adapters.

Interview tip

When asked "how would you add X?", answer with the seam: "that is a new PricingStrategy, registered here, and nothing else changes." Then say which test you would add. That is the extensibility signal interviewers grade at SDE2.

Tests

tests/test_parking_lot.py has 14 cases. The ones worth walking through in an interview are the concurrency test (40 arrivals, 10 spots, three gates — every spot used exactly once) and the declined-payment test (the car stays parked):

code/lld/parking_lot/tests/test_parking_lot.py — concurrency
def test_concurrent_gates_never_double_assign_a_spot(clock: FakeClock) -> None:
    lot = make_lot(clock, [{SpotType.COMPACT: 5}, {SpotType.COMPACT: 5}])
    gates = [EntryGate(f"in-{i}", lot) for i in range(3)]

    def arrive(i: int) -> str | None:
        try:
            return gates[i % 3].issue_ticket("car", f"CAR{i}").spot_id
        except LotFullError:
            return None

    with ThreadPoolExecutor(max_workers=12) as pool:
        results = list(pool.map(arrive, range(40)))
    spots = [s for s in results if s is not None]
    assert len(spots) == 10 and len(set(spots)) == 10  # every spot used exactly once
    assert results.count(None) == 30 and lot.free_spots() == 0
code/lld/parking_lot/tests/test_parking_lot.py — declined payment
def test_declined_payment_keeps_the_car_parked(clock: FakeClock) -> None:
    class Declines:
        def charge(self, amount: Money, method: PaymentMethod) -> bool:
            return False

    lot = make_lot(clock, [{SpotType.COMPACT: 1}])
    ticket = EntryGate("in", lot).issue_ticket("car", "C1")
    clock.advance(3600)
    with pytest.raises(PaymentDeclinedError):
        ExitGate("out", lot, processor=Declines(), clock=clock).process(ticket.id, PaymentMethod.CARD)
    assert ticket.status is TicketStatus.ACTIVE and lot.free_spots() == 0

The rest cover: per-started-hour pricing, the grace period, motorcycle preference then fallback, trucks spilling to the next floor and LotFullError, exiting twice and unknown tickets, the lost-ticket flow, the display board, and all three pricing strategies via parametrize. Run them with uv run pytest code/lld/parking_lot -q.

45-minute pacing

Minutes What to do What to say or write
0–5 Clarify Gates in parallel? Spot/vehicle fit rules? Pricing? Lost tickets? Out of scope: payments integration, reservations.
5–10 Entities Nouns on the board: Lot, Floor, Spot, Vehicle (+4), Ticket, Gates, Payment. Verbs become methods: park, process, calculate, choose.
10–18 Class diagram Draw structure first, then hang the three strategies and the observer off it. Mark the two locks.
18–35 Code Enums → Vehicle hierarchy → ParkingFloor.try_assign → ParkingLot.park loop → ExitGate.process. Say "reserve, charge, commit" while writing it.
35–42 Concurrency and tests Explain the floor lock and the PAYING state; describe the 40-arrivals test.
42–45 Extensions Reservations, EV pricing as a Decorator, per-gate nearest, multiple lots as the HLD hand-off.