Module 12: Parking Lot & Elevator
Why this matters
This is the first classic-problem module — and the first place the seven-step method from module 11 gets run for real, on complete designs with full implementations, not a partial sketch. Both problems here (Parking Lot, Elevator System) are among the most frequently asked LLD interview questions, precisely because each forces you to combine several patterns and principles from modules 04–10 into one coherent design rather than applying any single one in isolation.
Problem 1: Parking Lot
Requirements
Functional: a multi-level parking lot supports three vehicle types (motorcycle, car, truck) with three spot sizes (motorcycle, compact, large); a motorcycle can use any spot size, a car needs compact or large, a truck needs large only. On entry, a vehicle is assigned the nearest suitable available spot and receives a ticket recording entry time. On exit, a fee is computed from parked duration and paid before the spot is released.
Non-functional: multiple entry gates may operate concurrently (module 10's concurrency-safe design applies directly to spot assignment); pricing must be easy to change without touching the core allocation logic (module 04's OCP, satisfied via Strategy, module 08).
Assumptions stated up front (module 11, step 1): payment happens only on exit; a single physical lot (legitimate Singleton use, module 06); spot search proceeds level by level, first-fit within a level.
Entities and relationships
Applying module 11's steps 3–4 directly: ParkingLot *-- Level *-- ParkingSpot (composition — spots don't exist without their level, which
doesn't exist without the lot); ParkingSpot --> Vehicle (association —
a vehicle exists independently of being parked); Vehicle <|-- Motorcycle / Car / Truck (inheritance); Ticket --> ParkingSpot, Ticket --> Vehicle (association); ParkingLot --> PricingStrategy (association,
injected — DIP, module 04).
Class diagram
┌───────────────┐ ┌──────────┐ ┌────────────────┐
│ ParkingLot │ *-- │ Level │ *-- │ ParkingSpot │
│ (Singleton) │◇───────│ │◇───────│ │
├───────────────┤ ├──────────┤ ├────────────────┤
│ - levels │ │ - spots │ │ - size: SpotSize │
│ - pricing │ └──────────┘ │ - vehicle │
├───────────────┤ ├────────────────┤
│ + parkVehicle() │ │ + fits(vehicle) │
│ + unpark(ticket) │ │ + assign/vacate │
└───────────────┘ └────────┬───────┘
│--> Ticket │--> (association)
┌───────┴───────┐ ┌────────┴───────┐
│ Ticket │ │ Vehicle │ (abstract)
├───────────────┤ └────────┬───────┘
│ - entryTime │ <|--┴--|>--|>
│ - spot, vehicle │ ┌─────────┼─────────┐
└───────────────┘ ┌──────┴──┐ ┌───┴────┐ ┌──┴────┐
│Motorcycle │ │ Car │ │ Truck │
└─────────┘ └────────┘ └───────┘
PricingStrategy (interface) <── HourlyPricing (Strategy, module 08)
Implementation
A note on running the C# code as a real project, since these are full
programs now, not illustrative fragments: C# requires top-level
statements (the var lot = ... usage lines at the end) to come before
any type declaration in the same file — so put the class definitions
in their own file (e.g. Classes.cs) and keep only the usage lines in
Program.cs. This isn't a limitation of the design, just C#'s file-level
rule for where top-level statements are allowed; every classic-problem
module from here on assumes this same split.
from abc import ABC, abstractmethod
from enum import Enum, auto
from datetime import datetime
import threading
class SpotSize(Enum):
MOTORCYCLE = auto()
COMPACT = auto()
LARGE = auto()
class Vehicle(ABC):
def __init__(self, license_plate):
self.license_plate = license_plate
@abstractmethod
def required_sizes(self) -> set: # sizes this vehicle can legally use
...
class Motorcycle(Vehicle):
def required_sizes(self): return {SpotSize.MOTORCYCLE, SpotSize.COMPACT, SpotSize.LARGE}
class Car(Vehicle):
def required_sizes(self): return {SpotSize.COMPACT, SpotSize.LARGE}
class Truck(Vehicle):
def required_sizes(self): return {SpotSize.LARGE}
class ParkingSpot:
def __init__(self, size: SpotSize):
self.size = size
self.vehicle: Vehicle | None = None
def fits(self, vehicle: Vehicle) -> bool:
return self.vehicle is None and self.size in vehicle.required_sizes()
def assign(self, vehicle: Vehicle):
self.vehicle = vehicle
def vacate(self):
self.vehicle = None
class Level:
def __init__(self, spots: list[ParkingSpot]):
self.spots = spots
self._lock = threading.Lock() # encapsulated per module 10 — callers never see this
def find_and_assign(self, vehicle: Vehicle) -> ParkingSpot | None:
with self._lock: # critical section: check-then-assign, kept minimal
for spot in self.spots:
if spot.fits(vehicle):
spot.assign(vehicle)
return spot
return None
class Ticket:
def __init__(self, vehicle: Vehicle, spot: ParkingSpot):
self.vehicle = vehicle
self.spot = spot
self.entry_time = datetime.now()
class PricingStrategy(ABC): # Strategy, module 08
@abstractmethod
def calculate_fee(self, ticket: Ticket, exit_time: datetime) -> float: ...
class HourlyPricing(PricingStrategy):
def __init__(self, rate_per_hour=2.0):
self.rate = rate_per_hour
def calculate_fee(self, ticket, exit_time):
hours = max(1, (exit_time - ticket.entry_time).seconds // 3600 + 1)
return hours * self.rate
class ParkingLot: # Singleton, module 06 — genuinely one physical lot
_instance = None
_lock = threading.Lock()
def __init__(self, levels: list[Level], pricing: PricingStrategy):
self.levels = levels
self.pricing = pricing
@classmethod
def get_instance(cls, levels=None, pricing=None):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = ParkingLot(levels or [], pricing or HourlyPricing())
return cls._instance
def park_vehicle(self, vehicle: Vehicle) -> Ticket | None:
for level in self.levels:
spot = level.find_and_assign(vehicle)
if spot:
return Ticket(vehicle, spot)
return None # lot is full
def unpark(self, ticket: Ticket) -> float:
fee = self.pricing.calculate_fee(ticket, datetime.now())
ticket.spot.vacate()
return fee
# usage
lot = ParkingLot.get_instance(levels=[Level([ParkingSpot(SpotSize.COMPACT), ParkingSpot(SpotSize.LARGE)])])
ticket = lot.park_vehicle(Car("ABC-123"))
print(f"Fee: ${lot.unpark(ticket):.2f}")
Tradeoffs and extensions
- Why
find_and_assignas one locked method, notfind+assignseparately: module 10's lesson applied directly — if "find" and "assign" were two separate calls, two concurrent gates could both find the same spot before either assigns it (a classic check-then-act race). Combining them into one locked method closes that window. - Swap
HourlyPricingfor aMemberDiscountPricingorFlatRatePricingwith zero changes toParkingLot— this is OCP (module 04) and Strategy (module 08) working exactly as designed. - A real system would add an
Observer(module 08) — a display board subscribing to spot assign/vacate events — without touchingParkingLot's core logic at all.
Problem 2: Elevator System
Requirements
Functional: N elevators serve M floors; a hall call specifies a floor and a direction (up/down); an elevator car call specifies a destination floor from inside; the system dispatches the best available elevator to a hall call, and each elevator services requests along its current direction of travel before reversing (a simplified SCAN/elevator algorithm).
Non-functional: dispatch logic should be swappable (different buildings may want different elevator-selection heuristics) without touching elevator movement logic itself.
Assumptions: elevators move one floor per simulated "tick"; the dispatch strategy here is "nearest idle-or-same-direction elevator" — deliberately simplified, named explicitly as a simplification below.
Class diagram
┌─────────────────────┐ ┌──────────────┐
│ ElevatorController │ --> │ Elevator │
│ │ (many)│ │
├─────────────────────┤ ├──────────────┤
│ - elevators │ │ - currentFloor │
│ - dispatchStrategy │ │ - direction │
├─────────────────────┤ │ - targetFloors │
│ + requestElevator() │ ├──────────────┤
│ + step() │ │ + addTarget() │
└─────────────────────┘ │ + step() │
│--> (association) └──────────────┘
┌───────┴───────────┐
│ DispatchStrategy │ (Strategy, module 08)
└───────────────────┘
△
┌───────┴───────────┐
│ NearestElevator │
└───────────────────┘
Implementation
from abc import ABC, abstractmethod
from enum import Enum, auto
class Direction(Enum):
UP = auto()
DOWN = auto()
IDLE = auto()
class Elevator:
def __init__(self, id, current_floor=0):
self.id = id
self.current_floor = current_floor
self.direction = Direction.IDLE
self.targets: set[int] = set()
def add_target(self, floor: int):
self.targets.add(floor)
if self.direction == Direction.IDLE and self.targets:
self.direction = Direction.UP if floor > self.current_floor else Direction.DOWN
def step(self): # simulate one tick of movement
if not self.targets:
self.direction = Direction.IDLE
return
if self.direction == Direction.UP:
self.current_floor += 1
elif self.direction == Direction.DOWN:
self.current_floor -= 1
if self.current_floor in self.targets: # arrived — stop and pick up/drop off
self.targets.remove(self.current_floor)
print(f"Elevator {self.id} stopped at floor {self.current_floor}")
if not self.targets:
self.direction = Direction.IDLE
elif self.direction == Direction.UP and not any(t > self.current_floor for t in self.targets):
self.direction = Direction.DOWN # no more targets above — reverse (simplified SCAN)
elif self.direction == Direction.DOWN and not any(t < self.current_floor for t in self.targets):
self.direction = Direction.UP
class DispatchStrategy(ABC): # Strategy, module 08
@abstractmethod
def select(self, elevators: list[Elevator], floor: int) -> Elevator: ...
class NearestElevatorStrategy(DispatchStrategy):
def select(self, elevators, floor): # SIMPLIFICATION: ignores current direction/targets entirely
return min(elevators, key=lambda e: abs(e.current_floor - floor))
class ElevatorController:
def __init__(self, elevators: list[Elevator], strategy: DispatchStrategy):
self.elevators = elevators
self.strategy = strategy
def request_elevator(self, floor: int): # a hall call
best = self.strategy.select(self.elevators, floor)
best.add_target(floor)
return best
def press_floor(self, elevator: Elevator, floor: int): # a car call from inside
elevator.add_target(floor)
def step(self):
for elevator in self.elevators:
elevator.step()
controller = ElevatorController([Elevator(1), Elevator(2, current_floor=5)], NearestElevatorStrategy())
picked = controller.request_elevator(3)
controller.press_floor(picked, 7)
for _ in range(10):
controller.step()
Tradeoffs and extensions
NearestElevatorStrategyis a named simplification, on purpose. A production dispatch strategy would also weigh an elevator's current direction (an elevator already heading up, below the requested floor, is usually better than an idle one two floors closer) — swapping in a smarterDirectionAwareStrategyrequires zero changes toElevatororElevatorController, exactly the payoff Strategy (module 08) is for.- The
targetsset plus direction-reversal logic is a simplified SCAN/elevator algorithm — real elevator systems use more refined variants (LOOK, C-SCAN), but the shape (maintain a direction, serve requests along it, reverse when none remain ahead) is the same idea. - Concurrency (module 10): if requests can arrive from multiple
floors concurrently in a real system,
add_target/AddTargetandstep/Stepmutate shared state (targets,direction) — exactly the kind of critical section that needs the same lock-encapsulated- inside-the-class treatmentLevel.find_and_assigngot above.
Hands-on exercises
1. Parking Lot: add a spot-type observer
Add an Observer (module 08) to Level that gets notified whenever a
spot is assigned or vacated, and implement a simple
OccupancyDisplay observer that prints the current count of available
spots per level.
2. Parking Lot: swap the pricing strategy
Implement a FlatRatePricing strategy (a fixed fee regardless of
duration) and confirm swapping it into ParkingLot at construction
changes the computed fee with zero changes to ParkingLot itself.
3. Parking Lot: full-lot handling
Fill every spot in a small lot, then attempt to park one more vehicle —
confirm park_vehicle/ParkVehicle returns None/null rather than
crashing, and add a check in your calling code that prints "Lot is
full" in that case.
4. Elevator: verify the SCAN behavior
Send an elevator requests for floors 5, 2, and 8 (in that order) from
floor 0. Trace through step()/Step() calls by hand (or run it) and
confirm it visits floors in the order 2, 5, 8 — not the order the
requests arrived in — because it serves everything along its current
direction before reversing.
5. Elevator: a direction-aware dispatch strategy
Implement DirectionAwareStrategy, which prefers an elevator already
moving toward the requested floor in the requested direction over a
merely-closer idle one. Confirm it selects differently from
NearestElevatorStrategy in at least one scenario you construct.
Independent challenge
No code given.
Task: Extend the Parking Lot with multiple entry gates issuing
tickets concurrently and multiple exit gates processing payment
concurrently. Using module 10's guidance, identify every piece of
shared mutable state touched by more than one gate, and ensure each is
protected by a lock fully encapsulated inside the class that owns it
(not exposed to the gates). Write a short test that starts several
"gate" threads simultaneously calling park_vehicle/ParkVehicle on
the same lot with different vehicles, and confirm every vehicle gets a
distinct spot with no two vehicles ever assigned the same one.
Hint
Level.find_and_assign is already safe as written (module 10's
check-then-act fix). The remaining shared state to think about: if
gates are also updating a shared "available spot count" for a display
(exercise 1's Observer), that counter is itself shared mutable state
touched by every gate — protect it the same way, or better, derive it
by re-counting rather than maintaining a separately-mutated counter at
all (fewer places for a race to hide).
Common mistakes & troubleshooting
- Separating "find a spot" and "assign it" into two separate public method calls. This reopens exactly the race condition module 10 warned about — always keep check-then-act as one atomic, lock-encapsulated operation.
- Hard-coding vehicle-to-spot-size rules with
if/elifinsideParkingSpot.fits. Therequired_sizes()approach keeps that knowledge onVehicleitself (each vehicle knows what it can use) — adding a new vehicle type needs one new subclass, not an edit toParkingSpot. - Making
ParkingLota Singleton because "it's a nice pattern to show off," without the genuine one-physical-lot justification. Revisit module 06's caveat —ParkingLotis one of the legitimate uses, but that's a property of the specific problem (one lot), not a reason to Singleton-ifyLevelorParkingSpottoo. - Forgetting the elevator needs to reverse direction, not just stop when its target list is empty mid-scan. Without the reversal check, an elevator with targets both above and below its current floor after serving one direction's requests would get stuck never reaching the others.
- Treating
NearestElevatorStrategy's simplification as the final answer rather than a named tradeoff. Always be ready to say, out loud, what a real system would refine and why — this is exactly what module 11's step 7 (tradeoffs discussion) is for.
Checkpoint quiz
- Why does
find_and_assigncombine "search" and "assign" into one locked method instead of two separate public methods? - Which vehicle decides which spot sizes it can use, and why does that design choice make adding a new vehicle type cheap?
- Why is Singleton defensible for
ParkingLotspecifically, but notLevelorParkingSpot? - In the elevator's
step()/Step(), what causes a direction reversal, and why is that check necessary? - What's the one thing
NearestElevatorStrategydeliberately ignores, and what pattern lets you fix that without touchingElevator?
Answers
- To close the check-then-act race module 10 warned about — if "search" and "assign" were separate calls, two concurrent gates could both find the same available spot before either actually assigns it.
- The
Vehiclesubclass itself, viarequired_sizes()/RequiredSizes()— this keeps the vehicle-to-size compatibility rule with the vehicle, so adding a new vehicle type (e.g., a bus) is one new subclass, with no edits toParkingSpotorLevel. - Because there's genuinely one physical lot the whole system manages
— a true one-of-a-kind resource.
LevelandParkingSpothave many legitimate instances by design; Singleton-ing them would make no sense and would just be an unjustified global. - When there are no remaining targets in the current direction of
travel (checked via
any(t > current_floor ...)/Targets.Any(...)). It's necessary because otherwise an elevator with targets on both sides of its current floor would keep moving in its original direction forever, past requests behind it that it can never reach. - It ignores each elevator's current direction of travel — an elevator
already heading toward the requested floor might be a much better
pick than a merely-closer idle one. Strategy (module 08) lets you
swap in a
DirectionAwareStrategywith zero changes toElevatororElevatorController.
Interview questions
- "Walk me through your Parking Lot design and why you chose
Singleton for the lot itself."
One physical lot genuinely exists once per running system — a true
one-of-a-kind resource, which is exactly Singleton's legitimate use
case (module 06), unlike
LevelorParkingSpot, which have many instances by design. - "How would you make spot assignment safe if multiple entry gates
operate concurrently?"
Keep "find an available spot" and "assign it" as one method,
protected by a lock fully encapsulated inside the class owning the
spots (
Level), so the check-then-act sequence can't be interleaved by two gates — never expose the lock for callers to manage themselves. - "How would you change the pricing model without breaking existing
code?"
ParkingLotdepends on aPricingStrategy/IPricingStrategyabstraction, injected at construction (DIP, module 04) — a new pricing model is a new class implementing that interface, with zero changes toParkingLotitself (OCP, module 04, via Strategy, module 08). - "Explain how your elevator decides which floor to go to next." It maintains a set of target floors and a current direction; each step it moves one floor in that direction, stops and removes a target if it arrives at one, and reverses direction once no remaining targets are ahead of it in the current direction — a simplified SCAN/elevator algorithm.
- "What would you improve about your elevator dispatch strategy for
a real building?"
The current
NearestElevatorStrategyonly considers distance, not whether an elevator is already moving toward the request in the right direction — aDirectionAwareStrategywould weigh both, and swapping it in requires no changes toElevatororElevatorController, since dispatch logic is isolated behind aDispatchStrategyabstraction.
Further reading & sources
- Refactoring.Guru: Strategy pattern - revisit module 08's pattern, used for both pricing and dispatch here.
- SCAN disk/elevator scheduling algorithm (Wikipedia) - the real scheduling algorithm this module's simplified elevator logic is based on.
- Python:
threading.Lock/ Microsoft Learn:lockstatement - revisit module 10's concurrency primitives, applied directly inLevel.find_and_assign.
Next
13-library-and-vending-machine — two more classic problems, full guided solutions: a Library Management system and a Vending Machine.