Elevator System
Multi-car bank, request scheduling, the State pattern on the car itself. The runner-up to Parking Lot in interview frequency.
Context#
A building has several elevator cars sharing a common shaft bank. Riders press hall buttons on floors and destination buttons inside cars. The system must dispatch the right car to each hall request, drive the car to the destination, and keep doing this concurrently for many riders across many floors. The problem is the second-most-common OOD prompt after the parking lot because it forces a candidate to commit to a clean state machine on the car and a dispatch strategy on the bank — two patterns at once, in a domain anyone has touched.
The interviewer’s hidden objectives, in roughly the order they will be tested:
- Can you separate the car from the bank? The car is a state machine; the bank is a dispatcher. A monolith that does both is the first failure mode.
- Can you identify the entities — car, floor, request (hall vs car), dispatcher, direction — and the relationships, especially HAS-A vs IS-A?
- Can you produce a class diagram that uses the State pattern on the car instead of an enum-and-switch?
- Can you defend a dispatch strategy — nearest-car, SCAN, LOOK — and explain when each wins?
- Can you narrate a flow for two riders pressing buttons on different floors at the same moment without the dispatch becoming arbitrary?
Requirements (functional and non-functional)#
Scoping is the difference between a clean design and a sprawl. The defaults below are the ones most interviewers accept; surface anything outside them so you can finish.
Functional — in scope.
- A bank of
Nelevator cars serves a building withFfloors. - Hall requests carry a floor and a direction (up/down). Car requests carry only a destination floor and originate from inside a car.
- The bank dispatches an incoming hall request to one of the cars.
- A car moves at most one floor per tick, opens doors at the requested floor, and closes them before continuing.
- Cars can enter a Maintenance state and stop accepting requests.
Functional — out of scope (called out explicitly). Variable acceleration profiles, weight sensors, fire-alarm overrides, freight-vs-passenger cars, destination-dispatch panels in the lobby. Mentioned so the interviewer knows you saw them; not discussed unless asked.
Non-functional.
- A typical building has 4–8 cars and 30–60 floors — small numbers, in-process data structures are sufficient.
- Dispatch decisions must be sub-100 ms so the hall button does not feel laggy.
- Concurrency: multiple riders may press buttons simultaneously across the bank. The dispatcher must not assign the same request to two cars, and a car’s queue must not be corrupted by concurrent button presses inside it.
Use case diagram#
┌────────────────┐ │ Rider │ └────────┬───────┘ │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ [call elevator] [select floor] [hold door] │ │ │ ▼ ▼ ▼ ┌──────────────────────────────────────┐ │ Elevator Bank System │ └────────────────┬─────────────────────┘ ▼ ┌──────────────────────┐ │ Building Admin │ … put car in maintenance, view logs └──────────────────────┘Two actors (Rider, Admin) and three primary use cases on the rider side. Maintenance stays implicit but worth naming so the interviewer knows the lifecycle is on your radar.
Class diagram#
┌──────────────────────────┐ │ ElevatorBank │ ├──────────────────────────┤ │ cars : List<Elevator> │ │ dispatch : DispatchStrategy │ │ queue : BlockingQueue<HallRequest> │ ├──────────────────────────┤ │ submitHallRequest(r) │ │ tick() │ └──────────┬───────────────┘ │ 1..* ▼ ┌────────────────────────┐ │ Elevator │ ◇──── state: ElevatorState ├────────────────────────┤ │ id │ │ currentFloor │ │ direction │ │ pending : NavigableSet<Integer> │ ├────────────────────────┤ │ assign(r) │ │ step() │ │ openDoors() │ └────────┬───────────────┘ │ ┌───────────────────────┼──────────────────────┐ ▼ ▼ ▼ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ IdleState │ │ MovingState│ │ MaintenanceState│ └────────────┘ └────────────┘ └────────────────┘ │ ▼ ┌────────────┐ │StoppedState│ └────────────┘
┌──────────────────┐ ┌────────────────────────┐ │ HallRequest │ │ DispatchStrategy │◁── NearestCar, LookScan ├──────────────────┤ ├────────────────────────┤ │ floor │ │ choose(cars, r): Elevator │ │ direction │ └────────────────────────┘ └──────────────────┘Two patterns are doing the load-bearing work:
- State pattern on the elevator car —
Idle,Moving,Stopped,Maintenance. The car delegatesstep()to its current state; transitions are decisions the state itself makes. This kills the giantswitchthat would otherwise live onElevator. - Strategy pattern on the dispatcher —
NearestCarStrategy,LookScanStrategy. The bank does not switch on which algorithm is configured.
What is not in the diagram and that is deliberate:
- No
Requestgod class. Hall requests and car requests are different shapes (a car request has no direction) and travel different paths — hall request to the bank, car request directly to the car. Forcing them through one class hurts more than it helps. Directionis its own enum. Cars carry a current direction; SCAN/LOOK both depend on it. Smuggling direction into anint currentFloordelta is a classic LLD smell.
Sequence diagram (key flows)#
A hall request from a rider on floor 7 wanting to go down:
Rider HallButton ElevatorBank Dispatcher Elevator State │ press(7,DOWN) │ │ │ │ │ │──────────────►│ │ │ │ │ │ │ submit(r) │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ choose(cars,r)│ │ │ │ │ │──────────────►│ │ │ │ │ │ carId │ │ │ │ │ │◄──────────────│ │ │ │ │ │ assign(r) │ │ │ │ │ │──────────────────────────────►│ │ │ │ │ │ │ enqueue(7) │ │ │ │ │ │───────────►│ │ │ │ │ │ │ ... tick loop ... │ │ │ │ │ step() │ │ │ │ │ │───────────►│ │ │ │ │ │ move/stop │ │ │ │ │ │◄───────────│ │ │ │ │ │ openDoors()│ │ │ │ │ │◄───────────│ │ board, press(2) │ │ │ │ │───────────────────────────────────────────────────────────►│ │ │ │ │ │ │ enqueue(2) │ │ │ │ │ │───────────►│The dispatch decision (choose) is what the strategy parameterises. For NearestCar, the dispatcher iterates idle and moving-toward cars and picks the one with the smallest distance to r.floor. For LookScan, the dispatcher picks a car already heading in r.direction whose path passes through r.floor, falling back to the nearest idle car if no scanning car fits.
Activity diagram (for non-trivial state)#
The elevator-car state machine is where most subtle bugs live. Make the legal transitions explicit:
┌─────────┐ │ start │ └────┬────┘ ▼ ┌────────────────┐ ┌─────────►│ Idle │── request received ────┐ │ └────────────────┘ │ │ ▲ ▼ │ │ ┌───────────────┐ │ │ queue empty │ Moving │ │ │ └───────┬───────┘ │ │ │ reached target │ ┌───────────────┐ ▼ │ │ Stopped │◄────── stop at floor ───┤ │ └───────┬───────┘ │ │ │ door cycle complete │ │ ▼ │ │ (back to Moving if queue ≠ ∅) │ │ │ │ ┌────────────────┐ │ └──────────│ Maintenance │◄── admin disables ─────┘ └────────────────┘ (rejects all requests; only admin re-enables)Maintenance is reachable from any state and only the admin transitions out of it. Moving → Idle only goes through Stopped (door cycle) — there is no instantaneous arrival, which models the door interlock correctly.
Java implementation#
A representative slice — the car’s state machine and the bank’s dispatch — not the full system.
public enum Direction { UP, DOWN, NONE }
public interface ElevatorState { void onRequest(Elevator e, int floor); void step(Elevator e);}
public final class IdleState implements ElevatorState { public void onRequest(Elevator e, int floor) { e.queueFloor(floor); if (floor != e.currentFloor()) { e.setDirection(floor > e.currentFloor() ? Direction.UP : Direction.DOWN); e.setState(new MovingState()); } else { e.setState(new StoppedState()); } } public void step(Elevator e) { /* idle does nothing */ }}
public final class MovingState implements ElevatorState { public void onRequest(Elevator e, int floor) { e.queueFloor(floor); } public void step(Elevator e) { int next = e.currentFloor() + (e.direction() == Direction.UP ? 1 : -1); e.setCurrentFloor(next); if (e.pendingContains(next)) { e.removePending(next); e.setState(new StoppedState()); } }}
public final class StoppedState implements ElevatorState { private int doorTicks = 0; public void onRequest(Elevator e, int floor) { e.queueFloor(floor); } public void step(Elevator e) { doorTicks++; if (doorTicks < 3) return; // door open for 3 ticks if (e.pendingEmpty()) { e.setDirection(Direction.NONE); e.setState(new IdleState()); } else { int next = e.nextPending(); e.setDirection(next > e.currentFloor() ? Direction.UP : Direction.DOWN); e.setState(new MovingState()); } }}
public final class Elevator { private final int id; private int currentFloor = 0; private Direction direction = Direction.NONE; private final NavigableSet<Integer> pending = new TreeSet<>(); private ElevatorState state = new IdleState();
public Elevator(int id) { this.id = id; }
public synchronized void assign(int floor) { state.onRequest(this, floor); } public synchronized void step() { state.step(this); }
// accessors used by the state objects int currentFloor() { return currentFloor; } void setCurrentFloor(int f) { this.currentFloor = f; } Direction direction() { return direction; } void setDirection(Direction d) { this.direction = d; } void setState(ElevatorState s) { this.state = s; } void queueFloor(int f) { pending.add(f); } boolean pendingContains(int f) { return pending.contains(f); } void removePending(int f) { pending.remove(f); } boolean pendingEmpty() { return pending.isEmpty(); } int nextPending() { return direction == Direction.UP ? pending.ceiling(currentFloor) != null ? pending.ceiling(currentFloor) : pending.first() : pending.floor(currentFloor) != null ? pending.floor(currentFloor) : pending.last(); } public int id() { return id; }}
public interface DispatchStrategy { Elevator choose(List<Elevator> cars, HallRequest r);}
public final class NearestCarStrategy implements DispatchStrategy { public Elevator choose(List<Elevator> cars, HallRequest r) { return cars.stream() .filter(c -> !(c.stateName().equals("Maintenance"))) .min(Comparator.comparingInt(c -> Math.abs(c.currentFloor() - r.floor()))) .orElseThrow(() -> new IllegalStateException("No serviceable car")); }}
public final class ElevatorBank { private final List<Elevator> cars; private final DispatchStrategy dispatch;
public ElevatorBank(List<Elevator> cars, DispatchStrategy d) { this.cars = cars; this.dispatch = d; }
public void submitHallRequest(HallRequest r) { Elevator chosen = dispatch.choose(cars, r); chosen.assign(r.floor()); }
public void tick() { for (Elevator e : cars) e.step(); }}Notes the interviewer will look for:
- State objects, not an enum-and-switch.
IdleState,MovingState,StoppedStateare the textbook State pattern — each owns its own transition logic. Adding aMaintenancestate is one new class with no edits to the others. synchronizedon the car’s mutating methods. Many threads (the bank, the in-car panel, the maintenance panel) call into the car; the lock is correct and cheap at this scale.NavigableSet<Integer>for the pending queue.ceilingandfloorare exactly the SCAN/LOOK primitives — pick the next floor in the current direction, fall back to wrapping. Using a plainQueuehere is a tell that the candidate has not thought about SCAN.tick()on the bank. The mechanical “advance time by one unit” loop is what makes the system testable without sleeping. Inject a clock in production; in tests, drivetick()manually.
Trade-offs and extensions#
Decisions explicitly made and what they cost:
| Decision | Why | Cost if requirements change |
|---|---|---|
| State pattern on the car | Four states with distinct transition rules; an enum-and-switch grows fragile fast. | None at this size; only justify if the interviewer pushes back on apparent over-engineering. |
| Strategy pattern on the dispatcher | NearestCar is the default; LookScan, ZoneDispatch, ML-policy plug in without editing the bank. | None — this is the right shape. |
tick() loop, not a real-time scheduler | Discrete simulation is testable and the requirement is sub-100 ms decisions, not real-time motion control. | If the system must drive real hardware, replace tick() with a motion controller using the same state objects. |
Per-car synchronized lock | Cars are independent; contention is only between the dispatcher and the in-car panel of one car at a time. | At very high request rates, replace with a lock-free queue per car. |
| Hall and car requests as separate types | They have different shapes and entry points; one universal Request is a god class. | None — keep them separate. |
| Dispatch reads car state directly | Simple, no extra coupling — the bank already owns the cars. | If cars become remote (multi-building bank with shared dispatcher), introduce a CarSnapshot value object. |
Likely follow-up extensions and the shape of the answer:
- Zone dispatch. Each car owns a contiguous range of floors during peak hours; the bank picks based on which zone the request falls into. A new
ZoneDispatchStrategyimplementsDispatchStrategy— zero changes elsewhere. - Destination dispatch (lobby panel). Riders enter destination at the hall; the bank groups riders going to similar floors into the same car. Add a
DestinationRequesttype and a strategy that batches. - Power-saving idle. Idle cars drift to ground floor or to predicted high-demand floors. A scheduled
IdleStrategyruns on each idle car; the State pattern absorbs this without ripple. - Fire-alarm override. A broadcast event puts every car into a
FireServiceStatethat returns to the ground floor and stays there. New state class; existing classes untouched.
Mock interview follow-ups#
Questions interviewers reach for and the briefest correct answer:
- “Why the State pattern and not an enum?” — Each state has different transition rules and different per-tick behaviour; an enum-and-switch grows a new arm and a new bug surface every time we add a state. State objects co-locate the rules with the data.
- “What’s the state machine on
Elevator?” —Idle → Moving → Stopped → Idle(loop while queue non-empty);Maintenanceis reachable from any state and only the admin transitions out. Show the activity diagram. - “NearestCar vs SCAN vs LOOK — which would you pick?” — NearestCar is the default; it is fair and never starves a request. SCAN/LOOK win when traffic is heavy and biased in one direction (mornings up, evenings down). Strategy pattern means “pick at runtime”.
- “Two hall buttons pressed at the same instant — what happens?” — The bank’s submission goes through a synchronized method or a single-consumer queue; one dispatch runs to completion before the next; both requests land on the same or different cars deterministically.
- “How would you simulate a 30-floor building deterministically?” — Inject a clock; drive
tick()from the test; assert on car positions after each tick. The state pattern means the test reads like a story. - “Where would observers fit?” — On the car’s state transitions, so the building’s monitor panel and the analytics pipeline can subscribe without
Elevatorknowing they exist. (Observer pattern.) - “What if a car gets stuck between floors?” — Add a
StuckStatereachable fromMovingafter a watchdog timer; the dispatcher rejects further assignments to that car until an admin clears it. The state pattern absorbs this with one new class.
Related#
- Parking Lot — the sibling case study; same load-bearing patterns (State + Strategy), different domain.
- State Pattern — the car’s state machine is the textbook example.
- Strategy Pattern — the dispatcher’s plug-in algorithms are the textbook example.
- Observer Pattern — the answer to the “where would observers fit?” follow-up.
- Approaching the OOD Interview — the meta-script that produced this writeup’s structure.