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/elifladder). - 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.VehicleFactoryturns 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
FloorAvailabilityto listeners. - ParkingLot — the aggregate root: many floors, the ticket registry, the allocation strategy, the injected clock and ID generator. Built once in
mainand passed to the gates. - Ticket — plate, vehicle type, spot, entry time, and a status that moves
ACTIVE → PAYING → PAID(orLOST). - 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:
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"
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.
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.
@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.
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.
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.
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.
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.
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:
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:
ParkingFloor._lockguards 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).ParkingLot._tickets_lockguards the ticket registry and every ticket status transition.begin_checkoutmoves a ticketACTIVE → PAYINGunder this lock, so two exit gates handed the same ticket cannot both charge it; the loser getsTicketStateError.
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
RESERVEDspot status with an expiry and aReservationServicethat holds the floor lock when converting a reservation into an assignment; allocation skips reserved spots. - EV charging fees: a
ChargingPricingdecorator around anyPricingStrategythat 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:
ParkingLotkeeps tickets in a dict today; put that behind aTicketRepositoryprotocol 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):
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
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. |
Related¶
- Strategy — the pricing and allocation policies
- Factory Method —
VehicleFactoryand the registry idiom - Singleton — why the lot is injected instead
- Concurrency for LLD in Python — lock granularity and optimistic retries
- The LLD interview framework — the process this page follows
- Mock LLD interview: parking lot — the same problem as a 45-minute transcript