Lesson 22 of 31
Design an Elevator System
The elevator problem tests two skills at once: modeling a state machine (an elevator is moving, stopped with doors open, or idle) and choosing a dispatch policy (which car answers a call). It is also a problem where a real-time design tempts candidates into threads and sleeps. The design below avoids both by advancing the whole system with an explicit step call, so every run is deterministic. A final section shows how to drive the same controller from a BlockingQueue when requests arrive concurrently.
Requirements
Functional requirements
- A building has floors
0..=topand several elevators. - A person outside presses a hall call with a floor and a direction (up or down). The system assigns one elevator.
- A person inside presses a car call: a destination floor for that elevator.
- An elevator serves stops using the SCAN rule: keep moving in the current direction while there are stops ahead, serve them in order, then reverse if stops remain behind, otherwise become idle.
- Invalid floors are rejected.
Rules and assumptions
- One
stepmoves every elevator by one unit of time: one floor of travel, or one tick with doors open. - The dispatch policy must be replaceable, because "nearest car" and "least busy car" are both defensible answers.
- Out of scope: capacity and weight, door obstruction, emergency modes, real timing. They are extensions at the end.
Entities and relationships
| Noun | Kind of thing | Java form |
|---|---|---|
| Direction | two choices | enum Direction { UP, DOWN } |
| Elevator state | idle, moving with a direction, doors open | sealed interface ElevatorState with a record per state |
| Floor | a validated number | record Floor(int number) |
| Elevator | data plus rules | class with a TreeSet<Floor> of pending stops |
| Scheduler | behavior that varies | interface Scheduler |
| Controller | orchestrator | class that owns the elevators and a scheduler |
The controller owns the elevators in a List<Elevator> and refers to them by ElevatorId, which wraps the list index. Nothing holds a reference back to the controller, so the object graph is a plain tree.
classDiagram
class Controller {
-elevators: List~Elevator~
-top: Floor
-scheduler: Scheduler
+hallCall(floor, direction) ElevatorId
+selectFloor(elevator, floor)
+step() List~Tick~
}
class Elevator {
-floor: Floor
-state: ElevatorState
-stops: TreeSet~Floor~
+addStop(floor)
+step() Event
+costTo(floor) int
}
class ElevatorState {
<<sealed interface>>
Idle
Moving(direction)
DoorsOpen(heading)
}
class Scheduler {
<<interface>>
+pick(elevators, floor, direction) ElevatorId
}
Controller "1" *-- "many" Elevator
Controller o-- Scheduler
Elevator --> ElevatorState
Scheduler <|.. NearestCar
Scheduler <|.. LeastBusyAPI
public final class Controller {
public ElevatorId hallCall(Floor floor, Direction direction); // throws ElevatorException
public void selectFloor(ElevatorId elevator, Floor floor); // throws ElevatorException
public List<Tick> step();
public boolean isIdle();
}
sealed interface ElevatorError {
record InvalidFloor(Floor floor) implements ElevatorError {}
record UnknownElevator(ElevatorId elevator) implements ElevatorError {}
}A hall call returns which elevator was chosen, which is useful for the display above the door. step returns the events that happened, so a caller (a test, a UI, a log) observes the system without the controller printing anything. A bad floor or an unknown elevator is an expected input error, so the controller throws an unchecked ElevatorException that carries a sealed ElevatorError. The caller can switch over the error without a default branch.
Design decisions
The elevator is a sealed-interface state machine. The states are Idle, Moving(Direction) and DoorsOpen(Direction). The doors-open state remembers the heading, because SCAN must know which way the car was going after the stop. Storing a direction only in the records that need it makes the illegal state "idle but heading up" unrepresentable. A switch over a sealed interface needs no default, so adding a state, such as OutOfService, makes the compiler list every switch to update. For the general technique see state machines with sealed types.
| State | On step |
Next state |
|---|---|---|
Idle |
stop on this floor: open doors. Otherwise stops exist: head toward them. Otherwise nothing. | DoorsOpen, Moving, or Idle |
Moving(d) |
move one floor in d. If this floor is a stop, open doors. |
DoorsOpen(d) or Moving(d) |
DoorsOpen(h) |
close doors, then apply the SCAN rule from heading h. |
Moving, or Idle |
SCAN is a pure function. The rule "continue if stops are ahead, reverse if stops are behind, else stop" needs only the floor, the heading and the stop set. Writing it as a static method nextMove makes it easy to test with no elevator at all. It returns an Optional<Direction>, empty when there is nothing to do.
Stops are a TreeSet<Floor>. A set removes duplicates (two people pressing the same floor is one stop). A sorted set answers "is there a stop above the car" with higher(floor) and "below" with lower(floor), each in O(log n). Because the car only ever moves toward an existing stop, it never leaves the valid range.
The scheduler is an interface. Choosing a car is the varying behavior. Scheduler.pick looks at all elevators through a read-only ElevatorView and returns an ElevatorId, so a policy cannot change a car. Two implementations are given: NearestCar uses a cost estimate, and LeastBusy prefers the fewest pending stops. Ties go to the lowest ID so results are deterministic.
Time is a method, not a clock. Controller.step advances everything by one unit. A simulation is a loop of step calls, and a real system can drive the same method from one thread that owns the controller (see the last section). See testability and dependency injection.
The SCAN rule as a pure function
This program shows the core decision and prints it for several situations.
import java.util.List;
import java.util.Optional;
import java.util.TreeSet;
enum Direction {
UP, DOWN;
Direction opposite() {
return this == UP ? DOWN : UP;
}
}
record Floor(int number) implements Comparable<Floor> {
Floor {
if (number < 0) throw new IllegalArgumentException("floor below zero: " + number);
}
@Override public int compareTo(Floor other) { return Integer.compare(number, other.number); }
@Override public String toString() { return Integer.toString(number); }
}
public class Main {
/**
* SCAN: keep the heading while stops remain ahead, reverse if only
* stops behind remain, otherwise there is nothing to do.
*/
static Optional<Direction> nextMove(Floor floor, Direction heading, TreeSet<Floor> stops) {
boolean above = stops.higher(floor) != null;
boolean below = stops.lower(floor) != null;
boolean ahead = heading == Direction.UP ? above : below;
boolean behind = heading == Direction.UP ? below : above;
if (ahead) return Optional.of(heading);
if (behind) return Optional.of(heading.opposite());
return Optional.empty();
}
public static void main(String[] args) {
var stops = new TreeSet<>(List.of(new Floor(2), new Floor(7)));
record Case(int floor, Direction heading) {}
for (Case c : List.of(
new Case(4, Direction.UP), new Case(4, Direction.DOWN),
new Case(8, Direction.UP), new Case(1, Direction.DOWN))) {
System.out.println("at " + c.floor() + " heading " + c.heading() + ": "
+ nextMove(new Floor(c.floor()), c.heading(), stops));
}
System.out.println("no stops: " + nextMove(new Floor(4), Direction.UP, new TreeSet<>()));
}
}at 4 heading UP: Optional[UP]
at 4 heading DOWN: Optional[DOWN]
at 8 heading UP: Optional[DOWN]
at 1 heading DOWN: Optional[UP]
no stops: Optional.emptyAt floor 4 heading up the car continues because floor 7 is above. Heading down it continues because floor 2 is below. At floor 8 the only stops are below, so the car reverses, and with no stops at all the answer is empty. TreeSet.higher and lower return null when no such element exists, which is why the method compares them to null and hides that detail behind a boolean.
Full implementation
The program contains the elevator state machine, two schedulers, the controller, a simulation that prints one line per time step, and a queue-driven run described in the last section. Types other than Main are top-level package-private types in the same file, as the checker needs for a one-file program.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.TreeSet;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
enum Direction {
UP, DOWN;
Direction opposite() {
return this == UP ? DOWN : UP;
}
}
record Floor(int number) implements Comparable<Floor> {
Floor {
if (number < 0) throw new IllegalArgumentException("floor below zero: " + number);
}
@Override public int compareTo(Floor other) { return Integer.compare(number, other.number); }
@Override public String toString() { return Integer.toString(number); }
}
record ElevatorId(int index) {
@Override public String toString() { return "E" + index; }
}
sealed interface ElevatorError {
record InvalidFloor(Floor floor) implements ElevatorError {}
record UnknownElevator(ElevatorId elevator) implements ElevatorError {}
}
final class ElevatorException extends RuntimeException {
private final ElevatorError error;
ElevatorException(ElevatorError error) {
super(error.toString());
this.error = error;
}
ElevatorError error() { return error; }
}
sealed interface ElevatorState {
record Idle() implements ElevatorState {}
record Moving(Direction direction) implements ElevatorState {}
record DoorsOpen(Direction heading) implements ElevatorState {}
default Optional<Direction> travel() {
return switch (this) {
case Idle idle -> Optional.empty();
case Moving moving -> Optional.of(moving.direction());
case DoorsOpen open -> Optional.of(open.heading());
};
}
}
sealed interface Event {
record Moved(Floor floor) implements Event {}
record Opened(Floor floor) implements Event {}
record Closed() implements Event {}
record Waiting() implements Event {}
}
/** What a scheduler may see of a car. It cannot change it. */
interface ElevatorView {
Floor floor();
int pendingStops();
int costTo(Floor floor);
}
final class Elevator implements ElevatorView {
private Floor floor;
private ElevatorState state = new ElevatorState.Idle();
private final TreeSet<Floor> stops = new TreeSet<>();
Elevator(Floor start) { this.floor = start; }
static Optional<Direction> nextMove(Floor floor, Direction heading, TreeSet<Floor> stops) {
boolean above = stops.higher(floor) != null;
boolean below = stops.lower(floor) != null;
boolean ahead = heading == Direction.UP ? above : below;
boolean behind = heading == Direction.UP ? below : above;
if (ahead) return Optional.of(heading);
if (behind) return Optional.of(heading.opposite());
return Optional.empty();
}
void addStop(Floor target) { stops.add(target); }
Event step() {
return switch (state) {
case ElevatorState.Idle idle -> stepIdle();
case ElevatorState.Moving moving -> stepMoving(moving.direction());
case ElevatorState.DoorsOpen open -> stepDoorsOpen(open.heading());
};
}
private Event stepIdle() {
if (stops.remove(floor)) {
state = new ElevatorState.DoorsOpen(Direction.UP);
return new Event.Opened(floor);
}
Optional<Direction> direction = nextMove(floor, Direction.UP, stops);
if (direction.isEmpty()) return new Event.Waiting();
state = new ElevatorState.Moving(direction.get());
return step();
}
private Event stepMoving(Direction direction) {
floor = switch (direction) {
case UP -> new Floor(floor.number() + 1);
case DOWN -> new Floor(floor.number() - 1);
};
if (stops.remove(floor)) {
state = new ElevatorState.DoorsOpen(direction);
return new Event.Opened(floor);
}
return new Event.Moved(floor);
}
private Event stepDoorsOpen(Direction heading) {
state = nextMove(floor, heading, stops)
.<ElevatorState>map(ElevatorState.Moving::new)
.orElseGet(ElevatorState.Idle::new);
return new Event.Closed();
}
/** Rough time to reach a floor: distance, plus pending stops, plus a penalty when heading away. */
@Override
public int costTo(Floor target) {
int distance = Math.abs(floor.number() - target.number());
boolean headingAway = state.travel()
.map(d -> d == Direction.UP ? target.compareTo(floor) < 0 : target.compareTo(floor) > 0)
.orElse(false);
return distance + stops.size() + (headingAway ? 10 : 0);
}
@Override public Floor floor() { return floor; }
@Override public int pendingStops() { return stops.size(); }
boolean isIdle() {
return state instanceof ElevatorState.Idle && stops.isEmpty();
}
}
@FunctionalInterface
interface Scheduler {
ElevatorId pick(List<? extends ElevatorView> elevators, Floor floor, Direction direction);
}
final class NearestCar implements Scheduler {
@Override
public ElevatorId pick(List<? extends ElevatorView> elevators, Floor floor, Direction direction) {
int best = IntStream.range(0, elevators.size()).boxed()
.min(Comparator.<Integer>comparingInt(i -> elevators.get(i).costTo(floor)).thenComparingInt(i -> i))
.orElseThrow();
return new ElevatorId(best);
}
}
final class LeastBusy implements Scheduler {
@Override
public ElevatorId pick(List<? extends ElevatorView> elevators, Floor floor, Direction direction) {
int best = IntStream.range(0, elevators.size()).boxed()
.min(Comparator.<Integer>comparingInt(i -> elevators.get(i).pendingStops()).thenComparingInt(i -> i))
.orElseThrow();
return new ElevatorId(best);
}
}
record Tick(ElevatorId elevator, Event event) {}
final class Controller {
private final List<Elevator> elevators = new ArrayList<>();
private final Floor top;
private final Scheduler scheduler;
Controller(Floor top, List<Floor> starts, Scheduler scheduler) {
if (starts.isEmpty()) throw new IllegalArgumentException("at least one elevator is required");
for (Floor start : starts) elevators.add(new Elevator(start));
this.top = top;
this.scheduler = scheduler;
}
private void check(Floor floor) {
if (floor.compareTo(top) > 0) throw new ElevatorException(new ElevatorError.InvalidFloor(floor));
}
ElevatorId hallCall(Floor floor, Direction direction) {
check(floor);
ElevatorId id = scheduler.pick(List.copyOf(elevators), floor, direction);
elevators.get(id.index()).addStop(floor);
return id;
}
void selectFloor(ElevatorId id, Floor floor) {
check(floor);
if (id.index() < 0 || id.index() >= elevators.size()) {
throw new ElevatorException(new ElevatorError.UnknownElevator(id));
}
elevators.get(id.index()).addStop(floor);
}
List<Tick> step() {
var ticks = new ArrayList<Tick>();
for (int i = 0; i < elevators.size(); i++) {
ticks.add(new Tick(new ElevatorId(i), elevators.get(i).step()));
}
return ticks;
}
boolean isIdle() {
return elevators.stream().allMatch(Elevator::isIdle);
}
}
sealed interface Command {
record HallCall(Floor floor, Direction direction) implements Command {}
record SelectFloor(ElevatorId elevator, Floor floor) implements Command {}
record Step() implements Command {}
record Shutdown() implements Command {}
}
public class Main {
static String describe(List<Tick> ticks) {
return ticks.stream()
.map(t -> t.elevator() + " " + t.event())
.collect(Collectors.joining(" | "));
}
static void run(String name, Scheduler scheduler) {
var controller = new Controller(new Floor(9), List.of(new Floor(0), new Floor(9)), scheduler);
System.out.println("== " + name + " ==");
System.out.println("call 3 up -> " + controller.hallCall(new Floor(3), Direction.UP));
System.out.println("call 8 down -> " + controller.hallCall(new Floor(8), Direction.DOWN));
controller.selectFloor(new ElevatorId(0), new Floor(6));
try {
controller.hallCall(new Floor(12), Direction.UP);
} catch (ElevatorException e) {
System.out.println("floor 12 -> refused " + e.error());
}
int tick = 0;
while (!controller.isIdle() && tick < 30) {
tick++;
if (tick == 3) {
ElevatorId id = controller.hallCall(new Floor(4), Direction.DOWN);
System.out.printf("t%02d call 4 down -> %s%n", tick, id);
}
System.out.printf("t%02d %s%n", tick, describe(controller.step()));
}
System.out.println("idle after " + tick + " steps");
}
/** One thread owns the controller. Other threads only put commands on the queue. */
static void consume(Controller controller, BlockingQueue<Command> queue) {
try {
int tick = 0;
while (true) {
switch (queue.take()) {
case Command.HallCall call -> System.out.println(
"hall call " + call.floor() + " -> " + controller.hallCall(call.floor(), call.direction()));
case Command.SelectFloor select -> controller.selectFloor(select.elevator(), select.floor());
case Command.Step step -> {
tick++;
System.out.printf("t%02d %s%n", tick, describe(controller.step()));
}
case Command.Shutdown shutdown -> {
System.out.println("idle: " + controller.isIdle());
return;
}
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
static void runOnQueue() throws InterruptedException {
var controller = new Controller(new Floor(9), List.of(new Floor(0), new Floor(9)), new NearestCar());
BlockingQueue<Command> queue = new LinkedBlockingQueue<>();
System.out.println("== queue driver ==");
Thread consumer = Thread.ofPlatform().start(() -> consume(controller, queue));
queue.put(new Command.HallCall(new Floor(3), Direction.UP));
queue.put(new Command.SelectFloor(new ElevatorId(0), new Floor(6)));
for (int i = 0; i < 9; i++) queue.put(new Command.Step());
queue.put(new Command.Shutdown());
consumer.join();
}
public static void main(String[] args) throws InterruptedException {
run("nearest car", new NearestCar());
run("least busy", new LeastBusy());
runOnQueue();
}
}== nearest car ==
call 3 up -> E0
call 8 down -> E1
floor 12 -> refused InvalidFloor[floor=12]
t01 E0 Moved[floor=1] | E1 Opened[floor=8]
t02 E0 Moved[floor=2] | E1 Closed[]
t03 call 4 down -> E0
t03 E0 Opened[floor=3] | E1 Waiting[]
t04 E0 Closed[] | E1 Waiting[]
t05 E0 Opened[floor=4] | E1 Waiting[]
t06 E0 Closed[] | E1 Waiting[]
t07 E0 Moved[floor=5] | E1 Waiting[]
t08 E0 Opened[floor=6] | E1 Waiting[]
t09 E0 Closed[] | E1 Waiting[]
idle after 9 steps
== least busy ==
call 3 up -> E0
call 8 down -> E1
floor 12 -> refused InvalidFloor[floor=12]
t01 E0 Moved[floor=1] | E1 Opened[floor=8]
t02 E0 Moved[floor=2] | E1 Closed[]
t03 call 4 down -> E1
t03 E0 Opened[floor=3] | E1 Moved[floor=7]
t04 E0 Closed[] | E1 Moved[floor=6]
t05 E0 Moved[floor=4] | E1 Moved[floor=5]
t06 E0 Moved[floor=5] | E1 Opened[floor=4]
t07 E0 Opened[floor=6] | E1 Closed[]
t08 E0 Closed[] | E1 Waiting[]
idle after 8 steps
== queue driver ==
hall call 3 -> E0
t01 E0 Moved[floor=1] | E1 Waiting[]
t02 E0 Moved[floor=2] | E1 Waiting[]
t03 E0 Opened[floor=3] | E1 Waiting[]
t04 E0 Closed[] | E1 Waiting[]
t05 E0 Moved[floor=4] | E1 Waiting[]
t06 E0 Moved[floor=5] | E1 Waiting[]
t07 E0 Opened[floor=6] | E1 Waiting[]
t08 E0 Closed[] | E1 Waiting[]
t09 E0 Waiting[] | E1 Waiting[]
idle: trueIn the first run, the call for floor 3 goes to elevator 0 (three floors away versus six). Elevator 0 also has the car call for floor 6 and serves 3, then 6, in one upward sweep, which is SCAN at work. The call for floor 8 goes to elevator 1, which starts at floor 9. The late call for floor 4 shows the two policies differing. NearestCar scores elevator 0 as cheaper, so it takes the call and its upward sweep serves 3, 4 and 6 in order (it stops at 4 even though the call asked for down, because this version ignores the hall direction, which is the hall-direction row in the extensions table). LeastBusy ignores position, sees that elevator 1 has no pending stops, and sends it down from floor 8. That run finishes in 8 steps instead of 9, while in the nearest-car run elevator 1 sits idle after floor 8. Neither policy is best in general, and that difference is the point of making the scheduler an interface.
The rejected call for floor 12 never reaches an elevator, because the controller validates before it schedules. A negative floor never reaches the controller at all: the Floor constructor throws IllegalArgumentException, since that is a programming error and not user input.
Driving the controller from a queue
Real buttons are pressed from other threads, and a real system advances time from a timer. The state machine does not change. One thread owns the Controller and is the only code that calls it, so the controller needs no locks. Every other thread puts a Command on a BlockingQueue. The consume method above takes commands one at a time with take, which blocks while the queue is empty, and a switch over the sealed Command type handles each one. A Step command plays the role of a timer tick.
The queue also makes the run reproducible. A LinkedBlockingQueue delivers commands in the order they were put, and the only producer here is main, so the consumer sees the hall call, the car call, nine steps and the shutdown in that order. The consumer prints, main waits with join, and elevator 0 follows the same sweep as in the nearest-car run (3, then 6), because this run sends the same two calls and leaves out the others. With several producers the relative order of their commands would depend on scheduling, so a test that needs a fixed order sends from one thread, as here, and a production system accepts any order because each command is valid on its own. A Shutdown command ends the loop without Thread.stop or a shared flag.
Confinement to one thread is the reason Elevator and Controller have no synchronized methods. The alternative, a lock around every controller method, is also correct and allows direct calls from any thread. It makes the step timer and button presses contend for one lock. See blocking queue producer consumer for the queue itself.
Testing the state machine
Because there is no clock or thread, a unit test is a list of step calls with an expected event sequence. These checks use plain assert statements, since the course uses no test library and the checker runs programs with assertions enabled. They check that SCAN keeps its heading, reverses, and stops.
import java.util.List;
import java.util.Optional;
import java.util.TreeSet;
enum Direction { UP, DOWN }
record Floor(int number) implements Comparable<Floor> {
@Override public int compareTo(Floor other) { return Integer.compare(number, other.number); }
}
public class Main {
static Optional<Direction> nextMove(Floor floor, Direction heading, TreeSet<Floor> stops) {
boolean above = stops.higher(floor) != null;
boolean below = stops.lower(floor) != null;
boolean ahead = heading == Direction.UP ? above : below;
boolean behind = heading == Direction.UP ? below : above;
if (ahead) return Optional.of(heading);
if (behind) return Optional.of(heading == Direction.UP ? Direction.DOWN : Direction.UP);
return Optional.empty();
}
static TreeSet<Floor> stops(int... floors) {
var set = new TreeSet<Floor>();
for (int f : floors) set.add(new Floor(f));
return set;
}
static void keepsHeadingWhileStopsAreAhead() {
assert nextMove(new Floor(3), Direction.UP, stops(1, 5)).equals(Optional.of(Direction.UP));
}
static void reversesWhenOnlyStopsBehindRemain() {
assert nextMove(new Floor(6), Direction.UP, stops(1, 5)).equals(Optional.of(Direction.DOWN));
}
static void stopsWhenNothingIsPending() {
assert nextMove(new Floor(6), Direction.DOWN, stops()).isEmpty();
}
public static void main(String[] args) {
keepsHeadingWhileStopsAreAhead();
reversesWhenOnlyStopsBehindRemain();
stopsWhenNothingIsPending();
System.out.println("3 checks passed");
}
}3 checks passedExtensions
| Follow-up | Where the change goes |
|---|---|
| Capacity or overload | Add load to Elevator. costTo returns a very large cost when full, so the scheduler avoids it. |
| Hall call direction matters (a car going up should not pick up a person who wants down) | Store stops as (Floor, Optional<Direction>) pairs in a record, or keep two sets per car, upStops and downStops. nextMove filters by heading. |
| Express or zoned cars | A new Scheduler that maps floor ranges to elevators. No change to Elevator. |
| Maintenance mode or a car out of service | A new ElevatorState.OutOfService record. The compiler shows every switch to update, and schedulers skip that state. |
| Doors stay open several ticks | DoorsOpen(Direction heading, int remaining). The step decrements remaining before closing. |
| Peak traffic (lobby-heavy mornings) | A scheduler that parks idle cars at the lobby, using Idle cars as the parking signal. |
| Real time and concurrency | One thread owns the controller and receives commands on a BlockingQueue, as in the queue driver above. A ScheduledExecutorService can put a Step command on the queue at a fixed rate. |
| Observers for floor displays | step already returns events. Fan them out with an event bus. |
A common follow-up asks why SCAN and not "serve the nearest stop first". Nearest-first can starve a far stop when new near stops keep arriving. SCAN bounds the wait: every stop is served within one full sweep in each direction.
Summary
- Model the elevator as a sealed-interface state machine with data only in the records that need it:
Idle,Moving(Direction),DoorsOpen(Direction). - Express SCAN as a pure function of floor, heading and pending stops, so it is testable without any elevator.
- Keep pending stops in a
TreeSet<Floor>: duplicates disappear, andhigherandloweranswer "stops above" and "stops below". - Put the dispatch policy behind a
Schedulerinterface with deterministic tie-breaking, and let the controller own elevators by index-based IDs. Pass schedulers a read-only view. - Drive time with an explicit
stepmethod. The simulation is deterministic, needs no threads or sleeps, and returns events instead of printing. - For concurrent requests, confine the controller to one thread and feed it commands through a
BlockingQueue. - Validate floors at the controller boundary and report failures with an exception that carries a sealed error type.
