Guide contents

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..=top and 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 step moves 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 <|.. LeastBusy

API

Java
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.

Java
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<>()));
    }
}
Output
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.empty

At 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.

Java
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();
    }
}
Output
== 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: true

In 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.

Java
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");
    }
}
Output
3 checks passed

Extensions

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, and higher and lower answer "stops above" and "stops below".
  • Put the dispatch policy behind a Scheduler interface 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 step method. 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.