All chapters in this track
- 01Chapter 1: Why Object Orientation Exists
- 02Chapter 2: Classes, Objects, and Instances, Precisely Defined
- 03Chapter 3: The Four Pillars I: Encapsulation
- 04Chapter 4: The Four Pillars II: Abstraction
- 05Chapter 5: The Four Pillars III: Inheritance
- 06Chapter 6: The Four Pillars IV: Polymorphism
- 07Chapter 7: Composition vs. Inheritance
- 08Chapter 8: Interfaces and Abstract Classes: When to Use Which
- 09Chapter 9: SOLID, Part 1: Single Responsibility & Open/Closed
- 10Chapter 10: SOLID, Part 2: Liskov Substitution, Interface Segregation, Dependency Inversion
- 11Chapter 11: Basic UML for Communicating Design
- 12Chapter 12: Anti-Patterns: God Objects, Tight Coupling, Deep Hierarchies, the Fragile Base Class, Anemic Domain Models
- 13Chapter 13: OOP Idioms Across TypeScript and Python — and Where OOP Is the Wrong Tool
- 14Chapter 14: When OOP Breaks Down: Real-World Failure Modes
- 15Chapter 15: Design Exercise: Parking Lot System
- 16Chapter 16: Design Exercise: Library Management System
- 17Chapter 17: Design Exercise: Elevator Controller
- 18Chapter 18: OOP in Interviews: What a Strong Answer Looks Like
Chapter 17 of 18
Chapter 17: Design Exercise: Elevator Controller
Why this matters
The elevator controller is the classic exercise for the State pattern (formally covered in the companion Design Patterns book, but the core idea is squarely OOP fundamentals): an elevator's behavior — what happens when a button is pressed — depends entirely on its current state (idle, moving up, moving down, doors open). Modeling this with explicit state objects instead of a tangle of boolean flags and conditionals is one of the clearest demonstrations of polymorphism (Chapter 6) solving a real design problem.
Requirements
- A building has multiple floors and one or more elevators.
- Each elevator has a current floor, a direction (idle/up/down), and a set of pending requests (floor calls).
- Pressing a floor button inside the elevator, or a call button on a floor, should be handled correctly regardless of the elevator's current state (e.g., a call while doors are open behaves differently than a call while moving).
- (Clarifying question a strong candidate asks: "Is this a single elevator or a bank of elevators needing dispatch logic?" — assume a single elevator's state machine for the core design, and note dispatch as an extension.)
The design
Before — state modeled with boolean flags and branching (the anti-pattern this exercise exists to fix):
class Elevator {
currentFloor = 0;
isMoving = false;
isDoorOpen = false;
direction: "up" | "down" | "idle" = "idle";
requestFloor(floor: number): void {
if (this.isDoorOpen) {
// one set of rules
} else if (this.isMoving && this.direction === "up") {
// another set of rules
} else if (this.isMoving && this.direction === "down") {
// yet another set
} else {
// idle rules
}
// Every new state (e.g., "emergency stop", "maintenance mode") means
// finding and correctly extending every branch like this across the class.
}
}
This is Chapter 6's exact problem: behavior selected by manual branching on a combination of flags, scattered across every method that needs to check the elevator's state. Adding a new state means auditing every such branch.
After — each state is an object; the elevator delegates to its current state (the State pattern):
interface ElevatorState {
requestFloor(elevator: ElevatorController, floor: number): void;
openDoors(elevator: ElevatorController): void;
name(): string;
}
class IdleState implements ElevatorState {
name(): string { return "Idle"; }
requestFloor(elevator: ElevatorController, floor: number): void {
if (floor === elevator.currentFloor) {
elevator.setState(new DoorsOpenState());
} else {
elevator.setDirection(floor > elevator.currentFloor ? "up" : "down");
elevator.setState(floor > elevator.currentFloor ? new MovingUpState() : new MovingDownState());
}
}
openDoors(elevator: ElevatorController): void { elevator.setState(new DoorsOpenState()); }
}
class MovingUpState implements ElevatorState {
name(): string { return "MovingUp"; }
requestFloor(elevator: ElevatorController, floor: number): void {
elevator.enqueue(floor); // can't service a new request mid-move; queue it
}
openDoors(): void {
throw new Error("Cannot open doors while moving"); // a real safety invariant, enforced structurally
}
}
class MovingDownState implements ElevatorState {
name(): string { return "MovingDown"; }
requestFloor(elevator: ElevatorController, floor: number): void {
elevator.enqueue(floor);
}
openDoors(): void {
throw new Error("Cannot open doors while moving");
}
}
class DoorsOpenState implements ElevatorState {
name(): string { return "DoorsOpen"; }
requestFloor(elevator: ElevatorController, floor: number): void {
elevator.enqueue(floor); // will be serviced once doors close
}
openDoors(): void { /* already open, no-op */ }
}
class ElevatorController {
currentFloor = 0;
direction: "up" | "down" | "idle" = "idle";
private state: ElevatorState = new IdleState();
private pendingFloors: number[] = [];
setState(state: ElevatorState): void { this.state = state; }
setDirection(direction: "up" | "down" | "idle"): void { this.direction = direction; }
enqueue(floor: number): void { this.pendingFloors.push(floor); }
requestFloor(floor: number): void {
this.state.requestFloor(this, floor); // delegates — no branching on flags here at all
}
currentStateName(): string { return this.state.name(); }
}
from abc import ABC, abstractmethod
class ElevatorState(ABC):
@abstractmethod
def request_floor(self, elevator: "ElevatorController", floor: int) -> None: ...
@abstractmethod
def open_doors(self, elevator: "ElevatorController") -> None: ...
class IdleState(ElevatorState):
def request_floor(self, elevator: "ElevatorController", floor: int) -> None:
if floor == elevator.current_floor:
elevator.set_state(DoorsOpenState())
else:
elevator.direction = "up" if floor > elevator.current_floor else "down"
elevator.set_state(MovingUpState() if floor > elevator.current_floor else MovingDownState())
def open_doors(self, elevator: "ElevatorController") -> None:
elevator.set_state(DoorsOpenState())
class MovingUpState(ElevatorState):
def request_floor(self, elevator: "ElevatorController", floor: int) -> None:
elevator.enqueue(floor)
def open_doors(self, elevator: "ElevatorController") -> None:
raise ValueError("Cannot open doors while moving")
class DoorsOpenState(ElevatorState):
def request_floor(self, elevator: "ElevatorController", floor: int) -> None:
elevator.enqueue(floor)
def open_doors(self, elevator: "ElevatorController") -> None:
pass # already open
class ElevatorController:
def __init__(self) -> None:
self.current_floor = 0
self.direction = "idle"
self._state: ElevatorState = IdleState()
self._pending: list[int] = []
def set_state(self, state: ElevatorState) -> None:
self._state = state
def enqueue(self, floor: int) -> None:
self._pending.append(floor)
def request_floor(self, floor: int) -> None:
self._state.request_floor(self, floor)
The payoff mirrors Chapter 6's shape exactly: adding a new state (say, MaintenanceState or EmergencyStopState) means writing one new class implementing ElevatorState — ElevatorController.requestFloor never changes, and every existing state class is untouched.
Why this matters as a safety property, not just cleanliness
[SENIOR INSIGHT] Notice that MovingUpState.openDoors() throws rather than silently ignoring the request. This isn't incidental — it's encapsulation (Chapter 3) protecting a genuine safety invariant ("doors must never open while the car is moving") the same way Account.withdraw protected the non-negative-balance invariant in Chapter 1. Because the only way to open doors is through the current state object, and the moving states refuse, it is structurally impossible to open the doors while moving — the invariant isn't a convention documented somewhere, it's enforced by the design itself.
Trade-offs a strong candidate would raise
- Where does the pending-floor queue get serviced? A real implementation needs a loop (or event-driven trigger) that, on reaching a floor, checks
pendingFloorsand transitions state accordingly — worth sketching even briefly, since state transitions alone don't move the elevator without a data structure driving them (ElevatorControllerorchestrates, not any single state). - Multiple elevators and dispatch: extending to a bank of elevators adds a genuinely separate concern — a dispatcher choosing which elevator answers a floor call — worth explicitly scoping out ("I'd model that as a separate
ElevatorDispatcherclass, composed with, not part of, this state machine") to show awareness of the boundary without over-expanding the exercise. - Why State pattern instead of a
status: stringfield with a switch statement: naming the exact payoff — new states are added without touching existing code (Chapter 9's Open/Closed principle) and invalid transitions (opening doors while moving) are structurally prevented rather than merely checked for.
Common mistakes
- Falling back into the boolean-flags version under interview pressure, then bolting states on as more flags — recognize this as the exact anti-pattern the exercise is testing awareness of.
- Making
ElevatorControlleritself decide what's valid per state (e.g.,if (this.state.name() === "Moving") throw ...), which just relocates the type-checking Chapter 6 warned about — the state object itself should decide, via polymorphism, not the controller checking the state's identity. - Not handling the "already in the requested state" case (e.g., requesting the current floor while idle) — a real requirements gap that surfaces naturally once you trace through the state transitions carefully.
- Skipping the safety invariant entirely (letting doors open while moving) — missing the connection between this exercise and Chapter 3's core lesson about structurally-enforced invariants.
Discussion / interview questions
-
"Why model elevator behavior with state objects instead of a status field and a switch statement?" Strong answer: New states are added by writing a new class, without touching
ElevatorControlleror existing state classes (Open/Closed, Chapter 9); invalid actions for a given state (like opening doors while moving) are structurally prevented by that state's own method, not by a scattered set of manually maintained conditionals. -
"How does the State pattern relate to polymorphism?" Strong answer: Each state implements a shared interface (
ElevatorState), and the controller calls through that interface without knowing which concrete state it's talking to — the exact subtype-polymorphism mechanism from Chapter 6, applied to "what should happen next" rather than "what shape is this object." -
"Why does
MovingUpState.openDoors()throw instead of quietly doing nothing?" Strong answer: Frames it as a genuine safety invariant enforced structurally — the same pattern as Chapter 1's and Chapter 3's invariant-protection examples — rather than a detail incidental to the exercise. -
"How would you extend this design to a bank of multiple elevators?" Strong answer: Scopes it as a separate dispatching concern (which elevator should answer a call, based on proximity/direction/load) composed alongside the existing per-elevator state machine, rather than complicating the state classes themselves — showing the same "don't force unrelated concerns into one hierarchy" judgment from Chapter 7.
Chapter summary / cheat sheet
- The elevator controller is a canonical demonstration of the State pattern: behavior varies by current state, modeled as one class per state implementing a shared interface, with the controller delegating rather than branching.
- This is Chapter 6's polymorphism payoff applied to "what happens next" — new states are added without touching existing code (Chapter 9's Open/Closed).
- A state method refusing an invalid action (doors can't open while moving) is Chapter 3's invariant-protection principle, applied structurally rather than by convention.
- Scope dispatch across multiple elevators as a separate, composed concern — don't let it bloat the per-elevator state machine.