Design an Elevator System
Model a bank of elevators with hall and car calls, a pluggable dispatcher, LOOK-style stop ordering and a State-pattern car that moves, opens doors, idles and goes into maintenance.
Requirements
The elevator question looks simple, but it mixes three separate problems: modelling (cars, floors, buttons), scheduling (which car answers a call, and in what order a car visits its stops) and behaviour over time (a car is moving, stopped with doors open, idle or out of service). A strong answer keeps those three concerns in different classes.
Functional
- A building has floors
0..N-1andMelevator cars. - Hall calls: on each floor, a passenger presses UP or DOWN. The system assigns exactly one car.
- Car calls: inside a car, a passenger presses a destination floor. Only that car serves it.
- A car keeps moving in its current direction while it still has stops ahead, then reverses (the LOOK algorithm).
- Doors open at each stop for a short dwell time, then close.
- A car can be put into maintenance; it stops taking calls and its unserved hall calls are handed to other cars.
Non-functional
- Requests arrive concurrently from many button panels while the cars are moving.
- The dispatch policy must be swappable (nearest car, zoning, peak-hour modes) without touching car logic.
- Behaviour must be deterministic and testable, so time is modelled as discrete ticks (one tick = one floor of travel).
Out of scope (confirm first)
- Weight sensors, fire/emergency modes, door obstruction sensors, destination-dispatch keypads, physical motor control.
Ask early: "Do hall buttons have a direction?" Almost always yes, and that single fact is what makes direction-aware scheduling (and the LOOK algorithm) necessary.
Core Entities
| Entity | Responsibility | Key fields |
|---|---|---|
Direction (enum) |
UP, DOWN or IDLE; knows its opposite | - |
HallCall (record) |
A request from a floor panel | floor, direction |
Elevator |
One car: position, direction, pending stops; delegates behaviour to its current state | id, floor, direction, carStops, upHall, downHall, state |
ElevatorState |
Behaviour for one tick in a given mode | interface |
IdleState, MovingState, DoorsOpenState, MaintenanceState |
Concrete car states | dwell counter for doors |
DispatchStrategy |
Chooses which car takes a hall call | interface |
LookCostStrategy |
Estimates travel cost respecting each car's sweep | - |
ElevatorController |
Facade: accepts hall/car calls, dispatches, advances time | cars, strategy |
A car keeps three stop sets instead of one. Car calls (carStops) are served in either direction because the rider just wants to get out. Hall calls are split into upHall and downHall because a person waiting to go down should not be picked up by a car that is heading up, except at the turnaround point.
Class Diagram
classDiagram
class Direction {
<<enumeration>>
UP
DOWN
IDLE
+opposite() Direction
}
class HallCall {
+int floor
+Direction direction
}
class Elevator {
-int id
-int floor
-Direction direction
-TreeSet carStops
-TreeSet upHall
-TreeSet downHall
-ElevatorState state
+addCarCall(int) boolean
+addHallCall(HallCall) boolean
+step(int) void
+shouldStopHere() boolean
+pickDirection() Direction
+enterMaintenance() List
}
class ElevatorState {
<<interface>>
+step(Elevator, int) void
+acceptsRequests() boolean
}
class IdleState
class MovingState
class DoorsOpenState {
-int ticksLeft
}
class MaintenanceState
class DispatchStrategy {
<<interface>>
+select(List, HallCall) Optional
}
class LookCostStrategy {
+cost(Elevator, HallCall) int
}
class ElevatorController {
-List cars
-DispatchStrategy strategy
+requestElevator(int, Direction, int) void
+selectFloor(int, int) void
+step(int) void
+setMaintenance(int, int) void
}
ElevatorState <|.. IdleState
ElevatorState <|.. MovingState
ElevatorState <|.. DoorsOpenState
ElevatorState <|.. MaintenanceState
DispatchStrategy <|.. LookCostStrategy
Elevator --> ElevatorState
Elevator --> Direction
ElevatorController "1" *-- "many" Elevator
ElevatorController --> DispatchStrategy
ElevatorController ..> HallCall : creates
Design Patterns Used
| Pattern | Where | Why it is justified |
|---|---|---|
| State | ElevatorState and its four implementations |
What a car does on each tick depends entirely on its mode. Without State, step() becomes a large switch that every new mode (fire service, VIP, door-hold) must edit |
| Strategy | DispatchStrategy |
Buildings use different policies: nearest car, zoning (car A serves floors 0-10), up-peak mode in the morning. Each is a new class |
| Facade | ElevatorController |
Button panels only call requestElevator / selectFloor; they never see stop sets or states |
| Value objects | HallCall record, Direction enum |
Immutable, safe to pass between threads and to store in sets |
Two levels of scheduling
Interviewers often blur these, so separate them explicitly:
- Dispatch (system level): which car should answer a hall call? This is the
DispatchStrategy. - Sequencing (car level): in what order should one car visit its stops? This is the LOOK rule inside
Elevator.
The classic disk-scheduling algorithms map directly onto sequencing. SCAN sweeps all the way to the end of the building before reversing; LOOK reverses as soon as there is nothing further ahead, which is what real elevators do. FCFS (serve in arrival order) is simple but makes the car zig-zag, and shortest-seek-first can starve a passenger at the far end of the building.
The LOOK rule is three lines: stop at a floor if a car call is there, or a hall call in the same direction is there, or a hall call in the opposite direction is there and nothing lies further ahead (that floor is the turnaround point).
The LookCostStrategy estimates how many floors a car must travel to reach a caller:
- Idle car: plain distance.
- Car already heading toward the caller in the same direction: plain distance, because the caller is "on the way".
- Otherwise: distance to the car's farthest stop in its current direction, plus the distance back to the caller.
Ties are broken by fewer pending stops, then by car id, which keeps the result deterministic.
Java Implementation
One compilable Main.java (Java 17+). Time advances with step(tick); in production a scheduler thread would call it every few hundred milliseconds.
import java.util.*; import java.util.function.IntConsumer; enum Direction { UP, DOWN, IDLE; Direction opposite() { return this == UP ? DOWN : this == DOWN ? UP : IDLE; } } record HallCall(int floor, Direction direction) {} interface ElevatorState { String label(); void step(Elevator car, int tick); default boolean acceptsRequests() { return true; } default boolean doorsOpen() { return false; } } class IdleState implements ElevatorState { public String label() { return "IDLE"; } public void step(Elevator car, int tick) { if (car.serveCurrentFloor()) { car.openDoors(tick); return; } Direction next = car.pickDirection(); if (next == Direction.IDLE) return; car.setDirection(next); car.setState(new MovingState()); car.getState().step(car, tick); // start moving in this same tick } } class MovingState implements ElevatorState { public String label() { return "MOVING"; } public void step(Elevator car, int tick) { if (!car.hasStopsBeyond(car.getDirection())) { // nothing ahead: re-plan Direction next = car.pickDirection(); car.setDirection(next); if (next == Direction.IDLE) { car.setState(new IdleState()); return; } } car.moveOneFloor(); if (car.shouldStopHere()) { car.serveCurrentFloor(); car.openDoors(tick); } } } class DoorsOpenState implements ElevatorState { private int ticksLeft; DoorsOpenState(int dwellTicks) { this.ticksLeft = dwellTicks; } public String label() { return "DOORS_OPEN"; } public boolean doorsOpen() { return true; } public void step(Elevator car, int tick) { if (--ticksLeft > 0) return; Direction next = car.pickDirection(); car.setDirection(next); car.setState(next == Direction.IDLE ? new IdleState() : new MovingState()); } } class MaintenanceState implements ElevatorState { public String label() { return "MAINTENANCE"; } public boolean acceptsRequests() { return false; } public void step(Elevator car, int tick) { } } class Elevator { private static final int DOOR_DWELL_TICKS = 2; private final int id; private final int minFloor; private final int maxFloor; private int floor; private Direction direction = Direction.IDLE; private ElevatorState state = new IdleState(); private final TreeSet<Integer> carStops = new TreeSet<>(); private final TreeSet<Integer> upHall = new TreeSet<>(); private final TreeSet<Integer> downHall = new TreeSet<>(); Elevator(int id, int minFloor, int maxFloor, int startFloor) { this.id = id; this.minFloor = minFloor; this.maxFloor = maxFloor; this.floor = startFloor; } int getId() { return id; } synchronized int getFloor() { return floor; } synchronized Direction getDirection() { return direction; } synchronized ElevatorState getState() { return state; } synchronized void setDirection(Direction d) { direction = d; } synchronized void setState(ElevatorState s) { state = s; } synchronized boolean isInService() { return state.acceptsRequests(); } synchronized int pendingStops() { return carStops.size() + upHall.size() + downHall.size(); } private TreeSet<Integer> hallSet(Direction d) { return d == Direction.UP ? upHall : downHall; } synchronized boolean addCarCall(int target) { if (target < minFloor || target > maxFloor || !state.acceptsRequests()) return false; if (target == floor && state.doorsOpen()) return true; // already here, doors open carStops.add(target); return true; } synchronized boolean addHallCall(HallCall call) { if (!state.acceptsRequests()) return false; if (call.floor() == floor && state.doorsOpen() && (direction == call.direction() || direction == Direction.IDLE)) return true; hallSet(call.direction()).add(call.floor()); return true; } synchronized boolean hasStopsBeyond(Direction d) { if (d == Direction.UP) { return carStops.higher(floor) != null || upHall.higher(floor) != null || downHall.higher(floor) != null; } if (d == Direction.DOWN) { return carStops.lower(floor) != null || upHall.lower(floor) != null || downHall.lower(floor) != null; } return false; } // Farthest pending stop in direction d (used by the dispatcher's cost estimate). synchronized int farthestStop(Direction d) { int far = floor; for (TreeSet<Integer> s : List.of(carStops, upHall, downHall)) { if (s.isEmpty()) continue; far = d == Direction.UP ? Math.max(far, s.last()) : Math.min(far, s.first()); } return far; } // LOOK: keep going while there is work ahead, otherwise reverse, otherwise idle. synchronized Direction pickDirection() { if (direction != Direction.IDLE && hasStopsBeyond(direction)) return direction; if (direction != Direction.IDLE && hasStopsBeyond(direction.opposite())) return direction.opposite(); if (hasStopsBeyond(Direction.UP)) return Direction.UP; if (hasStopsBeyond(Direction.DOWN)) return Direction.DOWN; return Direction.IDLE; } synchronized boolean shouldStopHere() { if (carStops.contains(floor) || hallSet(direction).contains(floor)) return true; return hallSet(direction.opposite()).contains(floor) && !hasStopsBeyond(direction); } // Clears every request satisfied by stopping here; may flip direction at a turnaround. synchronized boolean serveCurrentFloor() { boolean served = carStops.remove(floor); if (direction == Direction.IDLE) { if (upHall.remove(floor)) { direction = Direction.UP; served = true; } else if (downHall.remove(floor)) { direction = Direction.DOWN; served = true; } return served; } served |= hallSet(direction).remove(floor); if (!hasStopsBeyond(direction) && hallSet(direction.opposite()).remove(floor)) { direction = direction.opposite(); served = true; } return served; } synchronized void moveOneFloor() { int next = floor + (direction == Direction.UP ? 1 : -1); floor = Math.max(minFloor, Math.min(maxFloor, next)); } synchronized void openDoors(int tick) { state = new DoorsOpenState(DOOR_DWELL_TICKS); System.out.printf("t=%2d E%d opens doors at floor %d (heading %s)%n", tick, id, floor, direction); } // Takes the car out of service and returns hall calls that must be re-dispatched. synchronized List<HallCall> enterMaintenance() { List<HallCall> orphaned = new ArrayList<>(); upHall.forEach(f -> orphaned.add(new HallCall(f, Direction.UP))); downHall.forEach(f -> orphaned.add(new HallCall(f, Direction.DOWN))); upHall.clear(); downHall.clear(); carStops.clear(); direction = Direction.IDLE; state = new MaintenanceState(); return orphaned; } synchronized void step(int tick) { state.step(this, tick); } @Override public synchronized String toString() { return "E" + id + "@" + floor + " " + state.label(); } } interface DispatchStrategy { Optional<Elevator> select(List<Elevator> cars, HallCall call); } class LookCostStrategy implements DispatchStrategy { int cost(Elevator car, HallCall call) { int cur = car.getFloor(); int target = call.floor(); Direction d = car.getDirection(); if (d == Direction.IDLE) return Math.abs(cur - target); boolean onTheWay = call.direction() == d && (d == Direction.UP ? target > cur : target < cur); if (onTheWay) return Math.abs(target - cur); int turn = car.farthestStop(d); return Math.abs(turn - cur) + Math.abs(turn - target); } public Optional<Elevator> select(List<Elevator> cars, HallCall call) { return cars.stream() .filter(Elevator::isInService) .min(Comparator.comparingInt((Elevator c) -> cost(c, call)) .thenComparingInt(Elevator::pendingStops) .thenComparingInt(Elevator::getId)); } } class ElevatorController { private final List<Elevator> cars = new ArrayList<>(); private final DispatchStrategy strategy; private final int floors; ElevatorController(int floors, int carCount, DispatchStrategy strategy) { this.floors = floors; this.strategy = strategy; for (int i = 1; i <= carCount; i++) cars.add(new Elevator(i, 0, floors - 1, 0)); } synchronized void requestElevator(int floor, Direction dir, int tick) { if (floor < 0 || floor >= floors || dir == Direction.IDLE) throw new IllegalArgumentException("bad hall call"); dispatch(new HallCall(floor, dir), tick); } private void dispatch(HallCall call, int tick) { Optional<Elevator> chosen = strategy.select(cars, call); if (chosen.isPresent() && chosen.get().addHallCall(call)) { System.out.printf("t=%2d hall call %d %s -> E%d%n", tick, call.floor(), call.direction(), chosen.get().getId()); } else { System.out.printf("t=%2d hall call %d %s -> no car available%n", tick, call.floor(), call.direction()); } } void selectFloor(int carId, int floor, int tick) { boolean ok = car(carId).addCarCall(floor); System.out.printf("t=%2d inside E%d: floor %d %s%n", tick, carId, floor, ok ? "accepted" : "rejected"); } synchronized void setMaintenance(int carId, int tick) { List<HallCall> orphaned = car(carId).enterMaintenance(); System.out.printf("t=%2d E%d -> maintenance, re-dispatching %s%n", tick, carId, orphaned); orphaned.forEach(call -> dispatch(call, tick)); } synchronized void step(int tick) { cars.forEach(c -> c.step(tick)); } Elevator car(int id) { return cars.get(id - 1); } List<Elevator> getCars() { return Collections.unmodifiableList(cars); } } public class Main { public static void main(String[] args) { ElevatorController ctl = new ElevatorController(10, 2, new LookCostStrategy()); Map<Integer, List<IntConsumer>> script = new HashMap<>(); script.computeIfAbsent(0, k -> new ArrayList<>()).add(t -> ctl.requestElevator(6, Direction.DOWN, t)); script.computeIfAbsent(0, k -> new ArrayList<>()).add(t -> ctl.requestElevator(2, Direction.UP, t)); script.computeIfAbsent(1, k -> new ArrayList<>()).add(t -> ctl.requestElevator(4, Direction.UP, t)); script.computeIfAbsent(2, k -> new ArrayList<>()).add(t -> ctl.selectFloor(2, 8, t)); script.computeIfAbsent(6, k -> new ArrayList<>()).add(t -> ctl.requestElevator(3, Direction.DOWN, t)); script.computeIfAbsent(9, k -> new ArrayList<>()).add(t -> ctl.selectFloor(1, 0, t)); script.computeIfAbsent(10, k -> new ArrayList<>()).add(t -> ctl.requestElevator(9, Direction.DOWN, t)); script.computeIfAbsent(11, k -> new ArrayList<>()).add(t -> ctl.setMaintenance(2, t)); script.computeIfAbsent(12, k -> new ArrayList<>()).add(t -> ctl.selectFloor(2, 5, t)); for (int tick = 0; tick <= 30; tick++) { final int t = tick; script.getOrDefault(tick, List.of()).forEach(action -> action.accept(t)); ctl.step(tick); } System.out.println("Final: " + ctl.getCars()); } }
Demo output
t= 0 hall call 6 DOWN -> E1 t= 0 hall call 2 UP -> E2 t= 1 hall call 4 UP -> E1 t= 1 E2 opens doors at floor 2 (heading UP) t= 2 inside E2: floor 8 accepted t= 3 E1 opens doors at floor 4 (heading UP) t= 6 hall call 3 DOWN -> E1 t= 7 E1 opens doors at floor 6 (heading DOWN) t= 9 inside E1: floor 0 accepted t= 9 E2 opens doors at floor 8 (heading UP) t=10 hall call 9 DOWN -> E2 t=11 E2 -> maintenance, re-dispatching [HallCall[floor=9, direction=DOWN]] t=11 hall call 9 DOWN -> E1 t=12 inside E2: floor 5 rejected t=12 E1 opens doors at floor 3 (heading DOWN) t=17 E1 opens doors at floor 0 (heading DOWN) t=28 E1 opens doors at floor 9 (heading DOWN) Final: [E1@9 IDLE, E2@8 MAINTENANCE]
Walkthrough:
- Both cars start idle at floor 0. The DOWN call at 6 goes to E1 (tie broken by id); the UP call at 2 goes to E2 because E1 now has a pending stop.
- At t=1 both cars are at floor 1 heading up, so floor 4 (UP) is "on the way" for both at a cost of 3 floors; the tie-breakers pick E1, which stops at 4 before continuing to 6.
- E1 opens at 6 heading DOWN: floor 6 was a turnaround point, so the car served the DOWN caller and flipped direction.
- The DOWN call at 3 is on E1's way down, so E1 stops at 3 before its car call to 0. That is LOOK sequencing: stops are visited in sweep order, not arrival order.
- When E2 goes into maintenance its pending hall call at 9 is re-dispatched to E1, and E2 rejects new car calls. E1 finishes its down sweep, then travels up to 9 and turns around there.
- Doors stay open for two ticks before the car moves again, which is why E1 reaches floor 9 at t=28 rather than t=26.
Concurrency Considerations
Button presses arrive on many threads while a single scheduler thread calls step. The design keeps this safe and simple:
- Each
Elevatoris its own monitor. All reads and writes of position, direction and stop sets aresynchronized, so a hall call added mid-tick cannot corrupt aTreeSetor be lost betweenshouldStopHereandserveCurrentFloor. - States call back into the car while holding the car's lock. Java monitors are reentrant, so this is safe, and the state objects themselves have no shared mutable data.
- Dispatch is serialized by synchronizing
requestElevatorandsetMaintenanceon the controller. The cost estimate reads several cars, so without that lock two simultaneous calls could both pick a car that is just entering maintenance.addHallCallreturnsfalsefor an out-of-service car, which is a second line of defence. - Lock ordering: the controller lock is always taken before a car lock, never the reverse, so no deadlock is possible.
A cost estimate is a snapshot; the car keeps moving after you read it. That is acceptable because the estimate only picks a car, while the car's own LOOK logic decides when to stop. Never let the dispatcher write a car's direction or floor directly.
At higher scale, replace locks with a single-threaded event loop per car: button threads push commands onto a BlockingQueue, and the car thread drains it at the start of each tick. That removes locking from the hot path and makes the car's behaviour easy to replay for debugging.
Extensions
| Requirement | Change |
|---|---|
| Capacity / weight limit | Add load to Elevator; a full car skips hall calls (car calls still served); the dispatcher adds a penalty for heavily loaded cars |
| Morning up-peak | UpPeakStrategy sends idle cars back to the lobby and prefers cars already at floor 0 |
| Zoning in tall buildings | ZonedStrategy maps floor ranges to subsets of cars |
| Destination dispatch | Hall panels accept a destination; the request becomes (from, to) so the dispatcher groups riders going to similar floors |
| Fire service / emergency | New EmergencyState: ignore hall calls, go to the recall floor, open doors |
| Displays and lanterns | Observer: Elevator publishes floor and direction changes; floor displays subscribe |
| Door obstruction | DoorsOpenState resets its dwell counter when a sensor event arrives |
Interview Tips
- Separate the two scheduling questions out loud: "which car" (dispatch strategy) and "which order" (LOOK inside the car).
- Explain why hall calls carry a direction and why that leads to separate up/down stop sets.
- Draw the state machine (Idle → Moving → DoorsOpen → Moving or Idle, with Maintenance from anywhere) before writing code.
- Compare FCFS, SCAN and LOOK briefly; choose LOOK and say why (no wasted travel, no starvation).
- Use discrete ticks so behaviour is deterministic; mention a real implementation would drive ticks from a scheduler.
- Volunteer the maintenance case: orphaned hall calls must be re-dispatched or riders wait forever.