LLD Case Study: Elevator System
An elevator interview prompt collapses three different systems into one drawing: the scheduler that decides which car moves, the state machine that decides what a car is allowed to
LLD Case Study: Elevator System
Advanced System Design & Software Architecture · Lesson 21 · Low-Level Design and Design Patterns
Lesson Overview
An elevator interview prompt collapses three different systems into one drawing: the scheduler that decides which car moves, the state machine that decides what a car is allowed to do next, and the safety circuit that must stop a car even when the scheduler is wrong. TechLearningPro's classroom tower has twelve floors and three cars. The software we are allowed to design is the scheduler and the car model a simulator can step. The motor drive, the door interlock, and the brake are a certified controller the software does not replace. The boundary matters. A class named Elevator that calls motor.start() as if it were a setter is pretending to own a safety system. This lesson's Elevator accepts a step: one floor of travel or one door cycle, and only when its mode says that motion is legal.
The design has a group controller, a per-car state machine, and a dispatch strategy the controller calls when a hall button is pressed. Two strategies are implemented far enough to compare: nearest car, and a SCAN-style assignment that will not reverse a car while it still has stops in its current direction. Car buttons and hall buttons are different commands. Capacity, a door that must be closed before motion, and an out-of-service car that returns its hall calls to the controller are part of the model, not follow-up trivia.
The structure reuses the course. Modes are the State pattern from Behavioral Design Patterns. Dispatch is a Strategy. The controller is the use case, injected with the strategy from lesson 19. Nothing here is a port to a fictional drive vendor. Time is a simulation tick so tests do not Thread.sleep. Concurrency is a single consumer of a command queue so two button presses cannot interleave inside one car's stop set. That choice is discussed against a lock per car, with the reason the queue is the one this design can test.
Learning Objectives
By the end of this lesson you will be able to:
- Separate hall calls, car calls, and car mode, and say which component owns each.
- Draw a state machine that refuses to change floors while the doors are open and refuses new assignments when the car is out of service.
- Implement a nearest-car dispatch and a direction-stable SCAN dispatch, and show a scenario where they differ.
- Keep a car from reversing while it has stops ahead in its current direction.
- Return a hall call to the controller when a car goes out of service.
- Step the building deterministically in a test, including capacity and a full car.
- Explain what this software does not control in a real hoistway, and what a second process would have to share.
Prerequisites
- Lesson 17's State and Strategy. Lesson 19's constructor injection. Lesson 20's habit of writing the invariant before the class: there the invariant was "one stall, one ticket"; here it is "no motion with doors open" and "no reversal while stops remain ahead."
- Java 21: sealed interfaces,
NavigableSet,ArrayDeque, records. JUnit 5. - A willingness to treat the simulation tick as the product. This lesson does not integrate a PLC, a message bus, or a real-time operating system. Those are different designs with different safety cases.
Detailed Core Concepts
Commands the building actually produces
A hall call is a request on a landing: floor and direction, up or down. The passenger does not choose the car. A car call is a destination pressed inside a car: elevator id and floor. The group controller assigns hall calls to cars. The car accepts car calls for itself. Mixing them into one Request(floor) type forces the controller to guess whether the passenger is inside. The guess is wrong at the lobby, where both buttons exist.
Each hall call is assigned to at most one car. A second press of the same hall button is the same call, not a second assignment. Identity is (floor, direction). Car calls are idempotent per (elevatorId, floor). The model stores them as a set of stops, not a multiset. Pressing 6 twice does not stop at 6 twice.
Car mode
The car's mode is a sealed type. The legal picture:
Idle: doors closed, no stops. A new stop toward a higher floor moves toMovingUp. A lower floor moves toMovingDown. The same floor opens the doors.MovingUp/MovingDown: doors closed. A tick advances one floor toward the next stop, or opens the doors if the current floor is a stop.DoorsOpen: a tick dwells. The next tick closes the doors and returns to moving if stops remain, otherwise to idle. A tick does not change the floor.OutOfService: ignores new car calls. Does not accept hall assignments. A tick does not move. Hall calls that were on this car return to the controller.
Door motion is simplified to "open for one tick, then closed." A real door has obstruct, reopen, and nudge. Those are events the certified door controller handles. Our mode has DoorsOpen so the scheduler cannot skip the constraint "while this is the mode, currentFloor is constant." If you need obstruct, add an event doorHeld that resets the dwell count. Do not add a motor API.
OutOfService is entered by an explicit fault command, not by a null pointer inside dispatch. Leaving it is an restore command from an attendant adapter. Restore places the car in Idle at its current floor with an empty stop set. It does not resume a half-executed trip. The passengers' destinations were returned to the hall or cancelled by the attendant procedure. Say which. This design returns hall-originated stops to the unassigned set and drops car calls, because the passengers inside a faulted car are the attendant's problem, and the people still on landings are the controller's. That is a product decision. Write it down. Dropping hall calls on the floor is the wrong default: those people are still waiting.
SCAN versus nearest car
Nearest car scores every in-service car for a hall call and assigns the minimum score. A simple score is the absolute distance in floors, with a penalty if the car is moving away, and a penalty if the car is full. It is easy to explain and it oscillates: a car moving up past floor 4 can be the nearest for a new down call at floor 4 and reverse, abandoning riders who pressed 10. Nearest car is acceptable when cars are often idle, which is a quiet building. It is a poor fit for a tower at class change.
SCAN, the elevator algorithm in the operating-systems sense, keeps a car's direction stable until there is no stop ahead. The controller assigns a hall call to a car only when the assignment does not require an early reversal. A car moving up is eligible for an up call at a floor above it, and for a stop it can serve on the way. A down call behind it is not eligible for that car until the car finishes the up sweep and turns. Idle cars are eligible for anything. If no car is eligible, the call waits in an unassigned set and is reconsidered after the next tick. Waiting is better than reversing a full car that is two floors from a cluster of car calls.
The implementation in this lesson stores, on each car, a NavigableSet<Integer> of stop floors plus the direction of the hall call when the stop was created by a hall button. A car call just inserts the destination. The next stop while moving up is the least stop strictly above the current floor, or the current floor if it is in the set. If none exist above, the car may reverse and serve the greatest stop below. That reversal happens only when the "above" set is empty. Dispatch is responsible for not inserting a below-stop into a car that is still going up, except when the car is idle. If dispatch inserts it anyway, the car's step function still does not reverse early, and the inserted stop waits — which means a bad dispatcher strands the request on that car. So the car enforces direction stability, and the dispatcher must only assign calls the car can serve without violating it, or leave them unassigned. Defense in both places. The car is the safety net for direction. The dispatcher is the utilization policy.
A third policy, "zone," pins cars to floor ranges. It is a strategy implementation with the same interface. This lesson does not code it. The interface is DispatchStrategy.assign(HallCall, List<Elevator>, Set<HallCall> waiting), returning the chosen elevator id or empty.
Capacity
Each car has a max passenger count and a current count. The count changes on passengerEntered and passengerExited commands the door adapter sends when it has a sensor or a button model. This simulation uses explicit commands so tests can set the count. A hall call is not assigned to a car whose count is at capacity. A car call from inside a full car is still legal: those passengers are already in. Refusing a car call because the car is full traps them. Capacity gates assignment of new hall calls, not the keypad.
The count is an integer the software believes. It can be wrong. The design does not pretend a load cell has been calibrated. When the building has a load signal, it arrives as a command that sets the count. Until then, the test supplies it.
One thread owns the mutable cars
Each Elevator's stop set and mode are mutated only on the controller thread. Button adapters enqueue HallCallCommand, CarCallCommand, Tick, Fault, and Restore. The controller drains the queue and then, on Tick, steps every car once. Tests call controller.accept(command) directly on one thread, which is the same mutual exclusion without a pool. A lock per car allows a hall assignment to read a car's direction while the car thread mutates it. That lock can be correct and is harder to test. The queue makes the interleaving the test's choice of command order. For a simulator and for a building server that is not itself the drive loop, a single thread is the right low-level design. A real group controller's cycle time is a product requirement; this model steps when told, so the test is the clock.
Do not put synchronized on assign and also mutate the car from step on another thread without a documented lock order. The queue deletes the second thread.
What a second instance would mean
Two JVMs must not both assign hall calls. The unassigned set and the cars' stop sets are one consistency domain. A database or a single active controller with a lease is the production shape. This case study runs one controller. Lesson 20's warning applies unchanged: an in-memory mutex is not a cluster mutex. Elevators make the failure vivid. Two controllers assign the same hall call to two cars, and both cars stop. Passengers are confused. The hoistway is not double-booked in the parking sense, but the service is wrong and the cars do extra trips. Leader election is out of scope. The design note says there is one active controller.
Architecture and Design Diagrams
mermaidstateDiagram-v2[*] --> IdleIdle --> MovingUp: stop aboveIdle --> MovingDown: stop belowIdle --> DoorsOpen: stop at this floorMovingUp --> DoorsOpen: arrived at stopMovingUp --> MovingUp: tick, not yet at stopMovingDown --> DoorsOpen: arrived at stopMovingDown --> MovingDown: tick, not yet at stopDoorsOpen --> MovingUp: doors close, stops aboveDoorsOpen --> MovingDown: doors close, stops belowDoorsOpen --> Idle: doors close, no stopsIdle --> OutOfService: faultMovingUp --> OutOfService: faultMovingDown --> OutOfService: faultDoorsOpen --> OutOfService: faultOutOfService --> Idle: restore
A tick while moving advances at most one floor. A tick while doors are open never advances the floor. Fault from any operational mode enters OutOfService and yields hall stops back to the controller. The diagram's OutOfService --> Idle is the attendant restore, with an empty stop set.
mermaidflowchart TBhall[Hall button adapter]carBtn[Car button adapter]queue[Controller queue]ctrl[GroupController]strategy[DispatchStrategy]cars[Elevator cars]hall --> queuecarBtn --> queuequeue --> ctrlctrl --> strategyctrl --> carsstrategy --> cars
The strategy reads car snapshots the controller passes in. It does not enqueue commands itself and does not open doors. The controller applies the assignment by adding a stop to the chosen car.
Assignment sequence for a hall call while car 1 is moving up:
mermaidsequenceDiagramparticipant Hallparticipant Ctrl as GroupControllerparticipant Scan as ScanDispatchparticipant Car as Elevator 1Hall->>Ctrl: HallCall floor 8 direction UPCtrl->>Scan: assignScan->>Car: read floor, direction, stops, countScan-->>Ctrl: elevator 1Ctrl->>Car: add stop 8Note over Car: direction stays UP if 8 is ahead
If car 1 is moving up at floor 9 and the call is floor 8 down, SCAN returns empty. The call stays unassigned until a car is idle or a car is sweeping down through 8.
Technical Explanation
Types
javapublic enum Direction { UP, DOWN }public record HallCall(int floor, Direction direction) {}public record CarCall(String elevatorId, int floor) {}public sealed interface CarMode permits Idle, MovingUp, MovingDown, DoorsOpen, OutOfService {CarMode onTick(Elevator car);boolean acceptsAssignment();}
Elevator holds id, current floor, mode, NavigableSet<Integer> stops, passenger count, and capacity. addStop rejects floors outside 1..floorCount. It inserts into the set. The mode decides when the stop is served.
Idle.onTick returns itself. Motion starts when a stop is added, via Elevator.retarget() which the controller calls after addStop:
javapublic void retarget() {if (mode instanceof OutOfService) {return;}if (mode instanceof DoorsOpen) {return;}if (stops.isEmpty()) {mode = new Idle();return;}if (stops.contains(currentFloor) && mode instanceof Idle) {mode = new DoorsOpen(1);return;}Integer above = stops.higher(currentFloor);Integer below = stops.lower(currentFloor);if (mode instanceof MovingUp || mode instanceof Idle && above != null && preferUp(above, below)) {mode = new MovingUp();return;}if (mode instanceof MovingDown || mode instanceof Idle) {mode = new MovingDown();}}
preferUp for an idle car chooses the nearer stop, with a stable tie toward up. A car already in MovingUp does not retarget to MovingDown while higher returns non-null. retarget from MovingUp keeps MovingUp if any stop is above, even if someone inserted a stop below. That is the direction invariant.
MovingUp.onTick:
java@Overridepublic CarMode onTick(Elevator car) {if (!car.doorsClosed()) {throw new DoorOpen(car.id());}if (car.stops().contains(car.currentFloor())) {car.clearStop(car.currentFloor());return new DoorsOpen(1);}Integer next = car.stops().higher(car.currentFloor());if (next == null) {return new MovingDown();}car.advanceOneFloorToward(next);if (car.stops().contains(car.currentFloor())) {car.clearStop(car.currentFloor());return new DoorsOpen(1);}return this;}
doorsClosed() is true for every mode except DoorsOpen. The throw is a backstop if a future edit calls onTick in the wrong mode. DoorsOpen.onTick decrements dwell and then asks the car to retarget into MovingUp, MovingDown, or Idle. It does not call advanceOneFloorToward.
advanceOneFloorToward changes currentFloor by exactly one. It does not jump to the stop. Tests can see the car pass a floor. Skipping floors hides bugs in "did we stop at 5?"
SCAN dispatch
javapublic final class ScanDispatch implements DispatchStrategy {@Overridepublic Optional<String> assign(HallCall call, List<ElevatorView> cars) {Optional<String> moving = cars.stream().filter(ElevatorView::inService).filter(car -> car.count() < car.capacity()).filter(car -> canServeWithoutReversal(car, call)).min(Comparator.comparingInt(car -> distance(car, call.floor()))).map(ElevatorView::id);if (moving.isPresent()) {return moving;}return cars.stream().filter(ElevatorView::idle).filter(car -> car.count() < car.capacity()).min(Comparator.comparingInt(car -> distance(car, call.floor()))).map(ElevatorView::id);}private boolean canServeWithoutReversal(ElevatorView car, HallCall call) {if (car.idle()) {return true;}if (car.direction() == Direction.UP && call.direction() == Direction.UP) {return call.floor() >= car.floor();}if (car.direction() == Direction.DOWN && call.direction() == Direction.DOWN) {return call.floor() <= car.floor();}return false;}}
ElevatorView is a snapshot record the controller builds so the strategy cannot mutate cars. A strategy that takes List<Elevator> and calls addStop hides a side effect inside a function the tests want to be a pure choice. The controller adds the stop after assign returns. If two hall calls are assigned in one drain, they are processed in queue order, each seeing stops committed by the previous assignment. That is deterministic.
NearestCarDispatch uses distance plus a large penalty when the car is moving away from the call, and it will assign that car anyway. The comparison test is the specification of "differ": a car at floor 9 moving up with a stop at 12, a hall call at floor 8 down, another car idle at floor 1. Nearest car may pick the car at 9 because the penalty still beats traveling from 1, or pick the idle car if the penalty is infinite. This lesson's nearest implementation uses an infinite penalty for moving away, so it also refuses the reversal — and then it is SCAN with a different idle tie-break. To keep the strategies honest, nearest car's penalty is finite: distance + 10 when moving away, and it may select the car at 9. The test asserts SCAN leaves the call unassigned or assigns the idle car at 1, and nearest assigns car at 9. Pick the numbers in the test and freeze them. Do not assert "the best" without defining the score.
Controller
javapublic final class GroupController {private final Map<String, Elevator> cars;private final DispatchStrategy dispatch;private final Set<HallCall> waiting = new LinkedHashSet<>();public void accept(ControllerCommand command) {switch (command) {case HallRequested(HallCall call) -> waiting.add(call);case CarRequested(CarCall call) -> cars.get(call.elevatorId()).addCarStop(call.floor());case Fault(String id) -> takeOut(id);case Restore(String id) -> cars.get(id).restore();case Tick ignored -> step();}assignWaiting();}private void step() {for (Elevator car : cars.values()) {car.tick();}}}
takeOut collects hall calls that car was serving. The car must remember which stops came from hall calls. A stop floor alone is not enough: a car call to 8 and a hall up at 8 share a floor. Store Set<HallCall> assignedHall on the car. On fault, add them back to waiting and clear the car's stops and assigned hall set. Car calls are dropped with the stops. assignWaiting runs after every command so a newly idle car picks up a waiting call on the same accept. Tick steps first, then assign, so a car that became idle this tick can take a call before the next button press.
addCarStop on an out-of-service car throws CarUnavailable. The keypad adapter maps that to a lit reject. It does not enqueue the stop for later.
Bounds
Floors are 1..N inclusive. Floor 0 does not exist in this building. A command outside the range throws NoSuchFloor before any stop set changes. The controller rejects a hall direction that the floor cannot serve: floor 1 has no down, floor N has no up. That check is a building invariant, not a strategy.
Practical Implementation or Design Walkthrough
Requirements
- Three cars, twelve floors, capacity four passengers each, for the numbers in the tests. The types take floor count and capacity as constructor arguments.
- Hall call
(floor, direction)is unique and assigned to at most one in-service car. - A car moving up does not begin moving down while any stop remains above it.
- Doors-open mode does not change
currentFlooron a tick. - An out-of-service car receives no new hall assignment, and its previous hall calls return to
waiting. - A car at capacity receives no new hall assignment and still accepts a car call.
- Dispatch strategy is replaceable. Nearest and SCAN are both constructible in tests without Spring.
- One thread mutates cars: the thread that calls
accept.
Non-functional: a tick is O(cars × log stops) from the navigable set. No I/O on accept. No Thread.sleep in the model. Domain types do not import Spring or a drive vendor client. The simulator is the deployment for this lesson. A later HTTP adapter can expose "press hall" as a driving adapter that enqueues. It is not required to prove the state machine.
Assumptions: cars do not pass each other in a shared shaft in a way the software must arbitrate; each car has its own shaft. Door timing is one tick of dwell. Passenger count is commanded, not sensed. There is a single active controller.
Walkthrough of a class change
Initial state: car A idle at 1, car B idle at 12, car C moving up at 6 with stops at 9 and 11, all counts zero.
Hall call (7, UP) arrives. SCAN's canServeWithoutReversal is true for C (moving up, call up, floor 7 ≥ 6). Distance is 1. A and B are idle, distances 6 and 5. The moving-eligible minimum wins: C. Controller adds stop 7 to C. C's direction stays up. Next ticks: C goes 6 → 7, opens doors, then continues toward 9. It does not go to 11 first and skip 7, because 7 is in the set and higher from 6 is 7 if 7 was inserted. From 6, stops are 7, 9, 11. higher(6) is 7. Good.
Hall call (4, DOWN) arrives while C is at 7 moving up. C is not eligible (direction mismatch). A at 1 and B at 12 are idle. Distance from A to 4 is 3, from B to 4 is 8. SCAN assigns A. A retargets to moving up toward 4, opens doors at 4. The passenger then presses car call 1. Stop 1 is inserted. A is in DoorsOpen. When doors close, stops below exist and none above, so A moves down to 1. Direction stability did not keep A moving up, because A had no stops above. The invariant is intact.
Fault C at floor 8 with hall stop 9 still assigned. waiting receives (9, UP) if that hall call was still in assignedHall. Car calls 11 are dropped. A and B may take (9, UP) on the following assignWaiting.
Tests
- Tick in
DoorsOpenleavescurrentFloorunchanged and ends inIdleor a moving mode according to remaining stops. - Car at 6 moving up, stops
{10}, hall(3, DOWN)under SCAN: afteraccept, car still moving up, call still waiting or assigned to a different idle car. With only one car, the call stays waiting. After the car serves 10 and becomes idle, a subsequentaccept(Tick)assignment picks it up and the car moves down. - Nearest versus SCAN on the frozen scenario in the technical section, asserting the chosen id.
- Capacity 1, count 1: hall call not assigned; car call inserted.
- Fault returns the hall call; a second car receives it; the faulted car's
acceptof a new hall does not add a stop. - Floor 1 down throws
NoSuchFloororIllegalHallDirectionand does not add towaiting. - Two
HallRequestedfor the same call assign once:assignedHallacross cars contains it once.
These tests construct GroupController with new and a strategy. Lesson 19's container is unnecessary. A Spring @Bean method may choose ScanDispatch in production configuration when this simulator is hosted in the app. The choice is one line in the composition root.
Trade-offs
- SCAN versus nearest. SCAN protects riders already committed to a direction and can leave a hall call waiting while a car passes on the other side of the building. Nearest reduces that wait and can reverse a car. The building's complaint tells you which cost is higher. Make the strategy a constructor argument so the comparison test is the decision record.
- Queue versus lock per car. The queue serializes all cars' logic and bounds throughput to one core for decisions, which is plenty for three cars and still plenty for twenty if ticks are coarse. A lock per car is justified when sensor events are high rate and a single thread cannot keep up. This lesson is not that system. The queue is the design that matches the tests.
- Remembering hall identity versus storing floors only. Floors are enough to stop. They are not enough to return the right hall call on fault. The extra set is the difference between "car goes to 8" and "the up button on 8 is being served."
- Jump to destination versus step one floor. Stepping is slower to simulate and reveals skipped stops. Jumping is shorter code and hides the doors-open constraint in a single transition. Step.
- Putting safety in the strategy only. A new strategy that assigns a down call to an up car would reverse the car if the car obeyed every insertion immediately. The car's refusal to reverse early is the invariant. Strategies are policies. The mode is the law. Same split as fit versus preference in lesson 20.
Enterprise Real-World Scenarios
A classroom tower at the break between periods
Twelve floors, three cars, a few hundred people, two minutes of demand. SCAN is the default strategy in the composition root. The operations lead can switch to nearest by configuration if the lobby fills while cars finish upper-floor sweeps, and the switch is a new bean, not an edit in MovingUp. The signage on each landing shows the assigned car when the controller has one, and "waiting" when SCAN has left the call unassigned. Showing a car id the strategy has not committed is how passengers walk away from the car that will actually arrive. The adapter reads assignedHall, not a guess.
Capacity at four is a teaching number; the real cars hold more, and the count still gates hall assignment. A car flagged out of service by the attendant's key returns hall calls. The drill is a Fault command from the attendant adapter, which is a driving adapter with its own authentication. The classroom app does not subscribe to the fire-alarm panel. If a later integration must, the alarm is another driving adapter that only enqueues Fault for each car and refuses Restore until a separate AlarmCleared command. The state machine does not gain a dependency on the panel's protocol.
Observability is per tick: car id, floor, mode name, stop set size, waiting hall calls. Those counters tell you whether SCAN is stranding the lobby. They do not include passenger names. There is no badge data in this system.
A campus with two shafts and a maintenance car
One car is OutOfService for a week. SCAN's filters already skip it. The failure mode to test is a strategy that iterates every car and assumes three in-service ids. ElevatorView.inService is the filter. A maintenance mode that moves the car under attendant car calls only, with hall assignment still refused, is OutOfService plus a narrower mode if the product needs the car to answer CarRequested during inspection. This design's OutOfService refuses car calls too, because an inspection move is a different procedure with a person on the car top, and this software should not move the car at all while the certified controller is in inspection. Restore returns it to Idle. If product later wants software-driven inspection, that is a new mode with a new driver, not a boolean ignoreSafety.
The second process question shows up because campus hosting wants two pods. The design stays single-active. The standby pod does not enqueue ticks. A lease, implemented later, is an adapter in front of accept: the pod without the lease rejects commands. Two pods both calling accept on split brains is the double-assignment bug. The lesson does not invent a lease protocol. It states the single-active constraint so a deployment review cannot "add a replica" in silence.
Best Practices
- Model hall calls and car calls as different commands with idempotent identity.
- Keep direction stability in the car mode, and eligibility in the strategy. Test a strategy that tries to mis-assign.
- Step one floor per tick. Assert doors-open does not move.
- Snapshot cars into views before a pure strategy runs. Apply the stop in the controller.
- On fault, return hall calls to the waiting set. Do not drop them.
- Gate hall assignment on capacity. Do not gate car calls on capacity.
- Mutate cars on one thread. Drive tests by
acceptorder, not by sleep. - Reject illegal landing buttons before they enter
waiting. - Inject
DispatchStrategy. Freeze a scenario where two strategies disagree and assert both results. - State the hoistway boundary: this model does not command a brake or a door interlock.
- State the single-active controller limit the way lesson 20 stated the single-JVM lock.
Common Mistakes and Anti-Patterns
One Request type for hall and car. The controller assigns a destination the passenger pressed inside the car to a different car, or treats a hall button as a car call with no elevator id. Split the commands.
Reversal whenever a nearer call appears. MovingUp switches to MovingDown because a new stop below has a smaller distance. Riders going to 12 watch their floor recede. Direction changes only when no stop remains ahead.
Strategy mutates the car. assign adds stops and the controller adds them again, or a test cannot ask "what would SCAN choose?" without changing the building. Return an id. Let the controller commit.
Jumping the car to the target floor in one tick. Intermediate hall stops inserted for floors you jumped are missed until the next trip. Advance one floor and test a stop on the way.
Fault clears the hall button. The landing stays dark and the passenger waits forever. Put the call back into waiting.
Capacity rejects the keypad. Occupants cannot leave. Capacity applies to new hall assignments.
A motor interface with up(), down(), stop() called from the web controller. You have taken responsibility for a safety device and skipped the mode. The web controller enqueues. The mode steps. A real drive is outside this process.
Sleep-based tests. They flake and they do not document order. Commands and ticks document order.
Two controllers in a cluster with in-memory waiting sets. Duplicate assignments. One active controller until you have a lease design.
Enum status beside a mode object. Callers read the enum, the mode object drifts, and doors open while the enum says moving. One representation. Derive the label from the mode.
Troubleshooting and Design Review
Symptom: a car passes the hall floor and never stops. The stop was not inserted, or advance jumped over it, or the stop was cleared by a fault path that also removed it from waiting. Log the stop set at each tick in the failing test. Assert higher(current) is the hall floor before the tick that should arrive.
Symptom: the car oscillates between two floors. A stop above and a stop below are both treated as immediate targets, and each tick retargets. MovingUp must ignore lower stops until the upper set is empty. A test with stops at current+2 and current-2 while moving up expects two ticks of upward motion before any downward motion.
Symptom: both cars arrive for one hall button. The call was assigned twice. waiting.remove must happen when the assignment is committed, and assignedHall is a set of calls, not a list of floors. A second HallRequested must not assign again. Search for addStop outside the controller.
Symptom: doors open and the floor changes. DoorsOpen.onTick calls advance. Remove the call. The backstop doorsClosed check on the moving mode should throw if the mode was wrong; it does not save you if the open mode itself moves the floor.
Symptom: after restore, the car sprints through old stops. restore did not clear the set. restore sets Idle, floor unchanged, stops empty, count unchanged or zero by attendant policy. This design zeros nothing about passengers automatically; the attendant command includes a count. Default the test to zero on restore so a full car is not immediately eligible, or keep the count if the sensor says so. Pick one in the command: Restore(id, count).
Symptom: lobby calls wait while an idle car sits at the lobby. assignWaiting does not run after Tick, or idle cars are excluded by a bug in canServeWithoutReversal that returns false for idle. The method returns true for idle before the direction checks. The unit test for an idle car at the call floor expects DoorsOpen after accept, because retarget sees a stop at the current floor.
Design review. Can the car reverse early, and which test forbids it? Can the floor change with doors open? Where do hall calls go on fault? Is the strategy pure? Is there one mutator thread? What does a second pod do? Which safety actions are explicitly out of scope? Is capacity applied to the right command? Are floor 1 down and floor N up rejected?
Interview Questions and Answers
Q1. What is the difference between a hall call and a car call in your model?
A hall call is (floor, direction) on a landing. The controller chooses the car. A car call is (elevatorId, floor) from inside a car and is added to that car only. They are different commands. A hall call is assigned at most once. A car call is not reassigned to another car when the first car is healthy.
Q2. How do you stop a car from reversing while passengers still have stops ahead?
The moving mode computes the next floor only in the current direction. MovingUp looks at stops.higher(currentFloor). It switches to down only when that returns empty. Dispatch is written to avoid handing the car a conflicting hall call, and the mode remains correct even if a stop below was inserted, because it will not turn until the upper set is empty.
Q3. Why is SCAN not the same thing as "always pick the nearest car"?
Nearest car minimizes a distance score and, in this lesson's finite-penalty version, may assign a call that requires the car to abandon its direction. SCAN assigns a moving car only when the call's direction matches and the floor is still ahead, otherwise an idle car, otherwise leaves the call waiting. A fixed scenario — one car high and moving up, one idle low, a down call in the middle — distinguishes them. If your two strategies always return the same id, one of them is mislabeled.
Q4. Where do doors and faults sit relative to dispatch?
They sit in the car mode. DoorsOpen ignores floor changes. OutOfService refuses assignments and movement. Dispatch only sees inService and idle flags on a snapshot. A strategy does not open doors and does not clear a fault. An attendant or an alarm adapter enqueues Fault and Restore.
Q5. What happens to people who pressed a hall button if their car faults?
Their HallCall is removed from that car's assignedHall and put back on the controller's waiting set. The next assignWaiting may give it to another car. Dropping the call is a product bug. Car-call destinations on the faulted car are not reassigned, because those passengers were inside a car that needs an attendant, and the model does not know who still wants to travel.
Q6. How do you test this without sleeping and without a GUI?
Call accept with hall and car commands and with Tick in the order the scenario describes. Assert floor, mode type, and the waiting set after each significant step. One tick moves at most one floor. The test is deterministic because one thread runs accept.
Q7. How does capacity interact with the two command types?
A car at count == capacity is skipped by dispatch for new hall calls. CarRequested is still inserted so occupants can leave. The count changes only through an explicit passenger command in this simulator. Hall assignment must not decrement capacity; boarding does.
Q8. Why a single controller thread instead of a lock on each car?
Button and tick events are low rate and must see a consistent stop set and direction together. A queue gives you that consistency and makes the test the scheduler. Per-car locks are a later design if a profiler shows the thread cannot keep up, and they need a lock order when the controller reads all cars to assign. This case study does not need them.
Q9. What would you refuse to implement inside Elevator?
A brake release, a door-interlock bypass, a vendor motor SDK, and a network call during tick. The certified drive owns motion safety. This type owns the next requested stop and the mode that decides whether a simulated step is legal. An HTTP controller that calls advance directly, skipping accept, is the same class of bypass and should be rejected in review.
Q10. How would you add a zone strategy without editing MovingUp?
Implement DispatchStrategy so cars are filtered to their zone before the SCAN eligibility checks, or as a separate class that only returns cars responsible for that floor. Inject it. MovingUp stays the direction law. If the zone strategy assigns an illegal direction, the car still will not reverse early; the call may sit in assignedHall unserved, which is why the strategy should use the same canServeWithoutReversal rule or leave the call waiting. A test with a zone car idle in range expects assignment, and a car outside the zone expects refusal.
Q11. Two pods run the service. What is wrong, and what is the smallest statement the design must make?
Each pod would keep its own waiting set and assign the same hall call. The smallest statement is that one active controller owns the waiting set and the cars. A replica may serve read-only status if it receives a stream of snapshots, and it may not call assign. A lease adapter can enforce that later. The in-memory design does not become safe because you put a load balancer in front of accept.
Q12. How does this case study relate to the parking-lot case study?
Both split a hard invariant from a replaceable preference. Parking: fits versus allocation policy, with select-and-mark atomic. Elevators: mode and direction versus dispatch strategy, with one thread as the critical section. Both snapshot a decision the outside world must not silently change — price on the ticket, assigned hall call on the car. Both are single-process designs with an explicit multi-instance limit. Neither hides a vendor client in the domain.
Hands-On Exercises
Exercise 1: Mode and direction
Problem. Implement Elevator modes for idle, moving up, moving down, doors open, and out of service. One tick moves at most one floor, and only when doors are closed and the mode is a moving mode.
Tasks.
- Write the state diagram and the test where doors are open and the floor is unchanged after a tick.
- Write the test where a car moving up with stops above and below does not decrease
currentFlooruntil the upper stops are cleared. - Implement
Faultso an assigned hall call returns to the controller and the car stops moving. - Reject hall down on floor 1.
- Keep mutation on the controller thread. Show the test calling
acceptonly.
Rubric. Requirements 15%. Mode and direction invariant 25%. Tests that step ticks 20%. Fault and illegal landing behavior 20%. Trade-offs 10%. Clarity 10%.
Exercise 2: Compare two dispatch strategies
Problem. Implement ScanDispatch and NearestCarDispatch behind one interface. Freeze a scenario where they choose different cars or one of them waits.
Tasks.
- Define the score or eligibility rule in writing before coding.
- Use
ElevatorViewsnapshots. Strategies return an id and do not mutate. - Assert both outcomes in one test class with the same initial building.
- Include a full car that must be skipped by both.
- State the single-active controller limitation.
Rubric. Same weights. A nearest implementation that is SCAN with different names does not pass the design criterion. The scenario must show a different decision.
Lesson Summary
The elevator low-level design is a group controller, a per-car mode, and a replaceable dispatch strategy. Hall calls and car calls are different, idempotent commands. The mode forbids floor changes while the doors are open and forbids an early reversal while stops remain ahead. SCAN uses that law as eligibility and will leave a call waiting; nearest car, with a finite penalty, may hand a call to a car that is moving away. Faults return hall calls to the waiting set. Capacity filters hall assignment only. One thread applies commands so the tests are the interleaving. The software does not own the brake, and a second process must not run a second assigner. Lesson 22 moves to a domain where the invariant is money: a charge that must not happen twice, a card number that must not be stored, and a provider call that sits outside the database transaction.
Further Reading
- Java
NavigableSet(TreeSet.higherandlower) for direction-stable next stops. - Behavioral Design Patterns for State and Strategy as used here.
- Lesson 20's critical-section discussion, for the parallel between one lock and one controller thread.
- A standard algorithms reference on the elevator (SCAN) disk scheduling algorithm, for the direction-stability idea this lesson adapts to cars. The adaptation is the hall-call direction and the idle-car fallback, which disk SCAN does not have.
- Hexagonal Architecture and Ports & Adapters for attendant and alarm inputs as driving adapters that only enqueue.
- The building's actual elevator safety code and the drive vendor's interface control document, when a project leaves the simulator. This lesson does not substitute for that document.