Design an elevator controller
N floors, one car, hall calls and car calls, SCAN scheduling and a state machine for doors.
- State machines and the State pattern — Behaviour that depends on a mode, without a wall of conditionals.
- Strategy — Swapping an algorithm at runtime by passing an object.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Observer and event listeners — Telling interested objects something happened, without knowing who they are.
- Heaps and priority queues in object design — Scheduling, ranking and timers with a heap behind a clean interface.
- Reading and drawing class diagrams — The notation interviewers expect, and what to leave out.
01The design, in the six-step framework
1. Requirements and use cases
A person on a floor presses up or down: a hall call. A person inside presses a floor number: a car call. The car serves calls in a sensible order, stops at each requested floor, opens and closes its doors, and never moves with the doors open. A hall call for up is served when the car is travelling up, so the passenger does not board a car going the wrong way. A display can learn when the car arrives at a floor.
Out of scope: several cars, which is the multi-elevator dispatcher; weight limits; emergency modes. The sources agree that keeping hall calls and car calls apart is the expected insight.
Constraints: behaviour is driven by a tick so a simulation and a test can step through time deterministically, with no threads or sleeping; the scheduling rule can be replaced without touching the car.
2. Core entities and relationships
- Elevator: the car. It holds its position, its direction, its door state, and three sets of pending stops.
- Direction and DoorState: small enums.
- Scheduler: decides the next stop from the state it is given. ScanScheduler is the classic sweep.
- listener: a callback fired on arrival, for displays and tests.
Starting from the use cases, notice what is missing: there is no Floor class and no Button class. Neither holds state or enforces a rule the car does not. Sets of integers are enough, and the diagram stays small. The scheduler is used and receives everything it needs as arguments, so it stays stateless.
3. Interfaces and key methods
class Direction(Enum):
UP = 1; DOWN = -1; IDLE = 0
class DoorState(Enum):
CLOSED = auto(); OPEN = auto()
class Scheduler(Protocol):
def next_stop(self, floor: int, direction: Direction,
up: set[int], down: set[int], car: set[int]) -> int | None: ...
class Elevator:
def hall_call(self, floor: int, direction: Direction) -> None:
"""Someone on a floor pressed up or down."""
def car_call(self, floor: int) -> None:
"""Someone inside pressed a floor button."""
def tick(self) -> None:
"""Advance one time step: move one floor, or open or close the doors."""
def on_arrival(self, listener: Callable[[int], None]) -> None: ...
Invariants: the car never changes floor while the door is OPEN; a floor is cleared from a call set only when the doors open there; a hall call for direction d is cleared only when the car is leaving in direction d, or has nothing left beyond it; pressing a button twice is harmless, because calls are sets.
4. Design patterns and the choices behind them
Separate sets for up, down and car calls
One sorted list of target floors loses information. A person at floor 5 going down should not stop a car rising to floor 8. Keeping _up, _down and _car apart lets the car stop only when the direction matches. This could be a pair of heaps for speed; with a few dozen floors, sets are simpler and clearing a floor is direct.
Scheduling as a strategy
SCAN, shortest-seek and first-come rules differ only in choosing the next stop. A strategy receives position and pending calls and returns a floor, so swapping it is one constructor argument. Passing the sets rather than the Elevator keeps the scheduler testable alone. The turning rule matters: travelling up with only a down call at floor 9 above, SCAN goes to 9, then reverses.
A tick-driven state machine
Each tick does exactly one thing: close doors, open doors, or move one floor. That small state machine makes "never move with doors open" true by construction, since the door check is the first line of tick. A thread per car with sleeps was rejected because no test could assert a timeline. A real controller calls tick from a timer.
Observer for arrival
A floor display or the dispatcher can subscribe through on_arrival with no change to the car, which is the plain observer case.
5. Core flow and code
- The car is idle at floor 0.
hall_call(5, DOWN)andhall_call(3, UP)arrive. tick: doors are closed and calls exist. The scheduler looks up: up calls at or above 0 include 3, so the target is 3. Direction becomes UP and the car moves one floor per tick.- At floor 3 the direction is UP and 3 is in
_up, so the doors open, the call clears and the listeners fire. The next tick closes the doors. - The passenger presses 7:
car_call(7). The car rises past 5, because 5 is a down call and the car is going up. - At 7 the doors open. No calls remain above, so the car reverses for the down call at 5.
- At 5 the direction is DOWN, the doors open and the call clears. With nothing pending, the next tick sets IDLE.
from __future__ import annotations
from enum import Enum, auto
from typing import Callable, Protocol
class Direction(Enum):
UP = 1; DOWN = -1; IDLE = 0
class DoorState(Enum):
CLOSED = auto(); OPEN = auto()
class Scheduler(Protocol):
def next_stop(self, floor: int, direction: Direction,
up: set[int], down: set[int], car: set[int]) -> int | None: ...
class ScanScheduler:
"""Continue in the current direction while anything lies ahead, then reverse."""
def next_stop(self, floor: int, direction: Direction,
up: set[int], down: set[int], car: set[int]) -> int | None:
order = (self._down, self._up) if direction is Direction.DOWN else (self._up, self._down)
for sweep in order:
target = sweep(floor, up, down, car)
if target is not None: # floor 0 is a valid target, so test for None
return target
return None
def _up(self, floor, up, down, car):
ahead = [f for f in up | car if f >= floor]
if ahead:
return min(ahead)
turn = [f for f in down if f >= floor] # go to the top down-caller, then reverse
return max(turn) if turn else None
def _down(self, floor, up, down, car):
ahead = [f for f in down | car if f <= floor]
if ahead:
return max(ahead)
turn = [f for f in up if f <= floor]
return min(turn) if turn else None
class Elevator:
def __init__(self, floors: int, scheduler: Scheduler, start: int = 0) -> None:
self.floors = floors
self.floor = start
self.direction = Direction.IDLE
self.door = DoorState.CLOSED
self._scheduler = scheduler
self._up: set[int] = set()
self._down: set[int] = set()
self._car: set[int] = set()
self._listeners: list[Callable[[int], None]] = []
def _check(self, floor: int) -> None:
if not 0 <= floor < self.floors:
raise ValueError(f"no floor {floor}")
def hall_call(self, floor: int, direction: Direction) -> None:
self._check(floor)
if direction is Direction.IDLE:
raise ValueError("a hall call has a direction")
(self._up if direction is Direction.UP else self._down).add(floor)
def car_call(self, floor: int) -> None:
self._check(floor)
self._car.add(floor)
def on_arrival(self, listener: Callable[[int], None]) -> None:
self._listeners.append(listener)
def _stops_here(self) -> bool:
if self.floor in self._car:
return True
if self.direction is Direction.UP and self.floor in self._up:
return True
if self.direction is Direction.DOWN and self.floor in self._down:
return True
# at a turning point or idle, serve whichever call is waiting here
target = self._scheduler.next_stop(self.floor, self.direction, self._up, self._down, self._car)
return target == self.floor
def _clear_calls(self) -> None:
self._car.discard(self.floor)
if self.direction is Direction.DOWN:
self._down.discard(self.floor)
if not self._calls_below():
self._up.discard(self.floor)
else:
self._up.discard(self.floor)
if not self._calls_above():
self._down.discard(self.floor)
def _calls_above(self) -> bool:
return any(f > self.floor for f in self._up | self._down | self._car)
def _calls_below(self) -> bool:
return any(f < self.floor for f in self._up | self._down | self._car)
def tick(self) -> None:
if self.door is DoorState.OPEN: # a closing tick; never move with doors open
self.door = DoorState.CLOSED
return
if not (self._up or self._down or self._car):
self.direction = Direction.IDLE
return
if self._stops_here():
self.door = DoorState.OPEN
self._clear_calls()
for listener in self._listeners:
listener(self.floor)
return
target = self._scheduler.next_stop(self.floor, self.direction, self._up, self._down, self._car)
self.direction = Direction.UP if target > self.floor else Direction.DOWN
self.floor += self.direction.value
6. Extensions, concurrency and what interviewers probe
- Load limit: a reported variant. Add a load field; when full, skip hall calls without clearing them. Only
_stops_herechanges. - Door hold: passengers need more than one tick. Add a countdown; a door-open button extends it, and the invariant stays because the door check comes first.
- Thread safety: buttons arrive from other threads. Queue the calls and apply them at the start of each tick, or take one lock around the public methods.
- Starvation: SCAN guarantees every call is reached within two sweeps, which is why it is preferred over nearest-first, which can leave an end floor waiting indefinitely.
- Test first: run the six-step scenario with a listener, assert the arrival order 3, 7, 5, and assert after every tick that the floor did not change while the door was open.
- Floor 0:
next_stopcompares with None rather than using truthiness, because the ground floor is a valid answer; that slip is easy to make in Python and hard to spot in a demo that starts at floor 0. - Common mistake: one queue of requests served in arrival order, which sends the car back and forth, and clearing a down call as the car passes upward.
02Where this question was reported
- Hello Interview · Hello Interview breakdown (Medium, free); ElevatorController/Elevator/Request; junior-mid-senior expectations
- Educative · Grokking LLD chapter 8
- DesignGurus · DesignGurus Grokking OOD case study
- ByteByteGo · ByteByteGo OOD chapter 8
- LLD Mastery · Google-tagged (Easy, free); request dispatching, state management; also single-lift SCAN and request-feasibility variants
- LLD Mastery · Google-tagged with Amazon/Microsoft/Uber; state machine, dispatch strategy, request queues
- PracHub · PracHub Google-tagged (Hard): N floors, M elevators, hall vs car calls; Controller/Scheduler classes
- PracHub · PracHub LLD guide: elevator control system attributed to Google, plus a senior variant (peak traffic, fairness, fault tolerance)
- interviewkickstart.com · Interview Kickstart Google list: 'How do you design an elevator system?'
- Exponent / Aced · Exponent/Aced question 'Design elevator (LLD)' (tagged Morgan Stanley, L4); related: /questions/235/design-elevator
- Codemia · Codemia Medium
- GitHub · awesome-low-level-design, Medium