Elevator system: one call, from a blank page to a design

Part of the free Low-Level Design course on LogicWiz, in the chapter: Case studies, step by step. The class-diagram exercises run in the app.

Elevator system: one call, from a blank page to a design

"Your lift visits floors in the order people pressed them. Count how far it travels while one person waits."

Priya, reviewing Lift.java in a mock round


The prompt, the first questions and the first classes

Priya reads the prompt aloud: "Design an elevator system for a 20-floor office building with 4 elevators. People press up or down on a floor, and press a destination inside the car."

Arjun reaches for the keyboard. Priya stops him. "The first 10 minutes are for questions. Each answer removes code you would otherwise write by guessing." He asks four, and writes down the assumption she gives for each.

  • Are there two kinds of button? Yes. Up or down on a floor is a hall call: the system must choose a lift. A floor button inside a car is a car call: it already belongs to that lift. They do different jobs, so the design keeps them apart.
  • How does time work? Assume a tick() method that moves every lift one floor. A real system gets this from the motor controller. A tick also makes the code easy to test, because nothing sleeps.
  • Must the rule for choosing a lift change? Yes. The building wants a different rule in the morning rush. So the rule must be swappable while the program runs.
  • Weight limits and fire mode? No. Maintenance only: ops can take a lift out of service. The others are extensions you mention aloud, not code you write.

Now the first list of classes. Priya says: "Underline the nouns and the verbs, then decide which nouns deserve a class." The nouns are building, floor, lift, door, button, request, direction and controller.

  • Elevator yes: it owns its floor, direction, state and stops.
  • ElevatorController yes: it receives hall calls and picks a lift.
  • Request yes, as a small record: the floor and the direction of a hall call.
  • Direction (UP, DOWN, NONE) and ElevatorState (IDLE, MOVING, DOORS_OPEN, MAINTENANCE) yes, as enums.
  • Floor no. A floor is a number with no behaviour. Button no. A press is a method call.
  • Door not yet. The model answer in the library draws a small Door class. In a first draft the state DOORS_OPEN is enough, and you add the class when doors get rules of their own.

Priya's plan for 90 minutes: questions until minute 10, classes and a diagram until minute 25, code for one lift answering one call until minute 60, then the swappable rule and "now add X" questions. Build the smallest design that answers one call from start to finish, then grow it.

The first draft, and where it breaks

Here is the draft Arjun writes first. Each lift keeps a queue of floors in the order they were pressed, and the controller picks the lift that is closest.

class Lift {
    final int id;
    int floor;
    final Queue<Integer> queue = new ArrayDeque<>();

    Lift(int id, int floor) { this.id = id; this.floor = floor; }

    void step() {
        if (queue.isEmpty()) return;
        int target = queue.peek();
        floor += Integer.compare(target, floor);
        if (floor == target) queue.poll();
    }
}

class Controller {
    final List<Lift> lifts;
    String mode = "NORMAL";

    Controller(List<Lift> lifts) { this.lifts = lifts; }

    Lift request(int floor) {
        Lift best;
        if (mode.equals("MORNING")) {
            best = floor <= 10 ? lifts.get(0) : lifts.get(1);
        } else {
            best = lifts.stream()
                .min(Comparator.comparingInt(l -> Math.abs(l.floor - floor))).get();
        }
        best.queue.add(floor);
        return best;
    }
}

Priya sets a scene with two lifts, so the numbers stay small. Lift 1 is at floor 5, going to 15. Lift 2 is at floor 12, going to floor 1. Someone on floor 8 presses DOWN.

Call at floor 8 (wants DOWN) went to lift 1
Lift 1 was 3 floors away, lift 2 was 4 floors away
The passenger waited 17 ticks

Lift 1 is closest, so it wins. But it is moving up. It drives past floor 8 without stopping, because floor 15 was pressed first. It reaches 15 after 10 ticks, then comes back down 7 floors. Lift 2 was moving down and would have reached floor 8 in 4 ticks.

Three things are wrong, and each one is a design lesson:

  • The score is only distance. It ignores which way the lift is going.
  • Each lift serves floors in arrival order, so it passes a floor it could have stopped at.
  • The morning-rush rule is an if inside request. The next rule edits the same method again.

"Ask what changes and what stays," says Priya. The rule that picks a lift changes by hour and by building. The job of the controller does not change: take a call, ask the rule, hand the call to the chosen lift.

The decision: the rule moves behind an interface

In the Strategy lesson you saw this shape: one rule per class, all behind one interface, chosen at runtime. The dispatch rule has exactly this shape. The interface is DispatchStrategy, and its one method takes the lifts and the request.

record Request(int floor, Direction direction) { }

interface DispatchStrategy {
    Elevator choose(List<Elevator> elevators, Request request);
}

final class NearestCarStrategy implements DispatchStrategy {
    private final int floors;
    public NearestCarStrategy(int floors) { this.floors = floors; }

    @Override
    public Elevator choose(List<Elevator> elevators, Request r) {
        return elevators.stream()
            .filter(e -> e.state() != ElevatorState.MAINTENANCE)
            .min(Comparator.comparingInt(e -> cost(e, r)))
            .orElseThrow(() -> new IllegalStateException("No elevator in service"));
    }

    // Distance, plus a penalty when the car is moving away from the caller.
    private int cost(Elevator e, Request r) {
        int at = e.currentFloor();
        boolean onTheWay = switch (e.direction()) {
            case NONE -> true;
            case UP -> r.floor() >= at && r.direction() == Direction.UP;
            case DOWN -> r.floor() <= at && r.direction() == Direction.DOWN;
        };
        return Math.abs(at - r.floor()) + (onTheWay ? 0 : 2 * floors);
    }
}

Read cost slowly, because this is the two-lift nearest-car rule. A lift is "on the way" if it is idle, or if it is heading toward the caller in the direction the caller wants to go. An on-the-way lift costs its distance. Any other lift costs its distance plus twice the number of floors. That penalty is bigger than any real distance, so a lift on the way always beats one that is not.

The controller now holds one strategy and nothing else about choosing. It can swap the strategy while the program runs, which is why the field is volatile: a write by one thread is seen by the others.

public final class ElevatorController {
    private final List<Elevator> elevators;
    private volatile DispatchStrategy strategy;   // swapped at runtime

    public ElevatorController(List<Elevator> elevators, DispatchStrategy strategy) {
        this.elevators = List.copyOf(elevators);
        this.strategy = strategy;
    }

    public void setStrategy(DispatchStrategy s) { this.strategy = s; }

    public Elevator requestPickup(int floor, Direction dir) {
        Elevator chosen = strategy.choose(elevators, new Request(floor, dir));
        chosen.addStop(floor);
        return chosen;
    }

    public void tick() { elevators.forEach(Elevator::step); }
}

The second fault belongs to the lift, not the controller. Each Elevator keeps its own stops, because only the lift knows where it is and which way it is going. The controller owns the choice. The lift owns its state.

classDiagram
  class ElevatorController
  class Elevator
  class DispatchStrategy {
    <<interface>>
    +choose(elevators, request) Elevator
  }
  ElevatorController "1" *-- "1..*" Elevator
  ElevatorController --> DispatchStrategy
  DispatchStrategy <|.. NearestCarStrategy
  DispatchStrategy <|.. ZoneStrategy

The key method: one call, tick by tick

A lift should not serve floors in arrival order. It should keep going in one direction, serving every stop ahead of it, and turn only when none are left. Disk schedulers use a similar idea and call it LOOK scheduling. Two sorted sets make it easy: upStops for floors above the lift and downStops for floors below.

public final class Elevator {
    private final int id;
    private int currentFloor;
    private Direction direction = Direction.NONE;
    private ElevatorState state = ElevatorState.IDLE;
    private final TreeSet<Integer> upStops = new TreeSet<>();    // floors above us
    private final TreeSet<Integer> downStops = new TreeSet<>();  // floors below us

    public Elevator(int id, int startFloor) { this.id = id; this.currentFloor = startFloor; }

    public synchronized void addStop(int floor) {
        if (state == ElevatorState.MAINTENANCE) throw new IllegalStateException("Elevator " + id + " is out of service");
        if (floor > currentFloor) upStops.add(floor);
        else if (floor < currentFloor) downStops.add(floor);
        else state = ElevatorState.DOORS_OPEN;                   // already here
    }

    public synchronized void step() {
        if (state == ElevatorState.MAINTENANCE) return;
        direction = nextDirection();
        if (direction == Direction.NONE) { state = ElevatorState.IDLE; return; }
        currentFloor += direction == Direction.UP ? 1 : -1;
        TreeSet<Integer> ahead = direction == Direction.UP ? upStops : downStops;
        state = ahead.remove(currentFloor) ? ElevatorState.DOORS_OPEN : ElevatorState.MOVING;
    }

    // LOOK: keep going while stops remain ahead; turn only when none are left.
    private Direction nextDirection() {
        if (direction == Direction.UP && !upStops.isEmpty()) return Direction.UP;
        if (direction == Direction.DOWN && !downStops.isEmpty()) return Direction.DOWN;
        if (!upStops.isEmpty()) return Direction.UP;
        if (!downStops.isEmpty()) return Direction.DOWN;
        return Direction.NONE;
    }
    // ... getters (currentFloor, direction, state), id() and setInService
}

Walk the same scene through the new code. Lift 1 is at floor 5, going UP, with upStops of {15}. Lift 2 is at floor 12, going DOWN, with downStops of {1}. The call is floor 8, DOWN.

  1. cost for lift 1: distance 3, but it is going up and the caller wants down, so it is not on the way. Cost is 3 + 40 = 43.
  2. cost for lift 2: distance 4, going down, floor 8 is below it, and the caller wants down. On the way, so cost 4.
  3. Lift 2 wins. addStop(8) puts 8 in downStops, because 8 is below floor 12. That set is now {1, 8}.
  4. Each tick, lift 2 moves one floor down. At floor 8, remove(8) succeeds, so the state becomes DOORS_OPEN.

Priya asks Arjun to run it. The setup builds the same scene, and the loop calls tick() until the doors open.

Before: lift 1 at floor 5, UP, MOVING
Before: lift 2 at floor 12, DOWN, MOVING
Call at floor 8 (wants DOWN) went to lift 2
tick 1: lift 1 at 6, lift 2 at 11
tick 2: lift 1 at 7, lift 2 at 10
tick 3: lift 1 at 8, lift 2 at 9
tick 4: lift 1 at 9, lift 2 at 8
The passenger waited 4 ticks, doors DOORS_OPEN

The wait fell from 17 ticks to 4. Notice that lift 1 passes floor 8 at tick 3 and does not stop. That is correct, because it is going the wrong way for this passenger.

Extend it, then try to break it

Extension: the morning rush. The building wants lift 1 to serve floors 1 to 10 and lift 2 to serve floors 11 to 20. This is a new rule, so it is a new class. It picks the zone for the floor, then reuses the nearest-car rule among the lifts of that zone.

final class ZoneStrategy implements DispatchStrategy {
    private final int zoneSize;
    private final int carsPerZone;
    private final DispatchStrategy inside;

    ZoneStrategy(int zoneSize, int carsPerZone, DispatchStrategy inside) {
        this.zoneSize = zoneSize;
        this.carsPerZone = carsPerZone;
        this.inside = inside;
    }

    @Override
    public Elevator choose(List<Elevator> elevators, Request r) {
        int zone = (r.floor() - 1) / zoneSize;                 // floors 1 to 10 are zone 0
        List<Elevator> mine = elevators.stream()
            .filter(e -> (e.id() - 1) / carsPerZone == zone)
            .toList();
        return inside.choose(mine.isEmpty() ? elevators : mine, r);
    }
}

With two lifts, new ZoneStrategy(10, 1, new NearestCarStrategy(20)) sends a call at floor 15 to lift 2. After setStrategy(new NearestCarStrategy(20)), the same call goes to lift 1, because lift 1 is already heading up toward floor 15.

Call at floor 15 (UP) went to lift 2
Same call, nearest car, went to lift 1

Neither ElevatorController nor Elevator changed. That is the open/closed principle at work.

Break it: two threads. A passenger presses a floor button on one thread while the tick thread runs step(). A TreeSet is not safe to change from two threads, so a stop could be lost. This is a race condition. The fix is synchronized on every Elevator method that reads or changes its state, as in the code above. Each lift is its own lock, so lifts never wait for each other.

A smaller trap remains. Between choose and addStop, ops may put the chosen lift into maintenance, and addStop then throws. The controller can catch that exception and call choose again. The strategy already skips lifts in maintenance, so the second try picks another lift. The run below takes lift 2 out of service first.

Call at floor 8 (DOWN) went to lift 1

One limit is worth saying aloud. addStop stores only the floor, so a lift going up would also stop at floor 8 for a passenger who wants to go down. The cost rule makes that rare, because it avoids such a lift. A complete answer also stores the direction each hall call wants.

💡 Tip: In the round, say the next step aloud even if you do not code it. "Each lift could own one thread and a blocking queue, so only that thread changes its state" shows you know where this design goes.

Your turn

Draw the core of this design in the class designer. Start from the draft where the controller chooses with an if. Pull the rule out behind an interface with two rules, and replace loose booleans with enums.

When your board turns green, open the Elevator System problem in the problem library. Design it again from a blank page without looking at the model answer, compare, and then follow its "Build it on your machine" kit to write the working version in your IDE.

📌 Summary: Ask first: hall call versus car call, ticks, swappable rule. Put the dispatch rule behind a strategy, give each lift its own up and down stop sets, and make lift methods synchronized.

What Arjun learns next

Priya moves from lifts to money. Next problem: friends share a trip, and the app must say who pays whom, in whole paise, with the fewest payments.

Check your understanding

  1. A hall call comes from floor 8 wanting DOWN. Lift A is 3 floors away and going up. Lift B is 4 floors away and going down. Which lift should the nearest-car rule choose?
  2. The building wants a different lift-choosing rule at 9 am. Which change leaves ElevatorController and Elevator untouched?
  3. Why does each Elevator keep its own upStops and downStops, instead of the controller keeping one queue of floors?
  4. A button thread calls addStop on a lift while the tick thread calls step on the same lift. What is the smallest sound fix?