System Design Road

Hard LLD interview problem

Design an elevator system

“Design an elevator system” is the LLD question with the most moving parts, and the one where a vague answer is easiest to give. The interviewer is looking for structure: two decisions kept apart, a scheduling rule you can defend with numbers, requests that arrive from everywhere at once, and cars that fail. This walkthrough builds it from nothing, tests it with a simulation, and explains each decision.

27 min read4 diagrams15 sections

This gives away every stage. For a clean attempt, practise it first, then come back.

The short version

  • An elevator system makes two different decisions. Dispatching picks which car answers a button on a floor. Scheduling picks which stop one car serves next. Keep them in separate classes.
  • A single car should use LOOK: keep going one way while there’s work ahead, then turn round. First-come-first-served zig-zags; nearest-first can strand a far floor.
  • A hall call has a direction. A car going up stops for “up” calls on the way and collects “down” calls on its way back.
  • Buttons are pressed from many threads, but the building moves in one place. Buttons leave messages in a queue; one controller thread reads them and moves every car. No locks inside cars.
  • Dispatchers are swappable strategies. In our simulation, cost-based dispatch cut average waits from 28 to 10 ticks in the morning rush, but made the worst wait slightly longer. Say both.

How this walkthrough is built

Most LLD problems have one hard idea. The elevator has three: choosing which car answers a call, choosing the order a car serves its stops, and handling requests from dozens of buttons at once. Strong answers keep these three apart. Weak answers mix them into one Elevator class that does everything.

We’ll build it in interview order:

StepWhat you doWhat comes out of it
1. Understand the problemAsk questions, write down requirementsHall calls, car calls, and what “good” means
2. Break it into entitiesNouns, then the two decisions, then the scheduling trapSeparate classes for dispatching and scheduling
3. Choose a directionTop-down for designing, bottom-up for codingThe order you’ll work in
4. Design each classDecide what each class knows and doesA card per class
5. Connect the classesPick the right relationshipsWho owns whom
6. Draw the diagramBuild it up in three versionsThe complete class diagram
7. Name the patternsStrategy, Observer, a command queueAnd why the car isn’t a State pattern
8. Walk the flowsA call, a ride, a car that turns roundThree rules you’d otherwise miss
9. Write the codeBottom-up, one class at a timeCode that compiles and runs
10. Make it safeButtons on many threads, one buildingA queue and a single controller
11. Extend itRush hour, maintenance, fire, capacityData from a real simulation

The one sentence to remember: the building decides which car; each car decides which stop.

Step 1Understand the problem

Picture the real thing first

Think of an office building at 9am. Twenty floors, three lifts. On the ground floor, a crowd presses the up button. On floor 12, someone presses down to go for coffee. Inside lift B, three people have each pressed a different floor number.

There are two kinds of button here, and they’re very different. The buttons on a floor (up and down) say “someone here wants to go in this direction”, but not where. Any lift could answer. The buttons inside a lift say “this lift must stop at floor 14”. Only that lift can answer.

And nobody wants to wait. What “good” means is subtle: short waits on average, but also nobody waiting forever, and lifts not wasting energy rushing back and forth.

Ask before you design

Interviewer

Interviewer: Design an elevator system for a building.

You

You: Sure. How many floors and how many lifts?

Interviewer

Interviewer: Twenty floors, three lifts. Make the numbers configurable.

You

You: On each floor, are there up and down buttons, or a keypad where you type your destination?

Interviewer

Interviewer: Up and down buttons. Inside each lift, a button for every floor.

You

You: Who decides which lift answers a floor button?

Interviewer

Interviewer: The system does. That’s part of what we want to see.

You

You: What matters most: average wait, longest wait, or energy?

Interviewer

Interviewer: Average wait first, but nobody should be stuck for ages.

You

You: Do lifts need to handle maintenance?

Interviewer

Interviewer: Yes. A technician can take one out of service.

You

You: Anything about emergencies?

Interviewer

Interviewer: A fire alarm should send all lifts to the ground floor. Maybe as a follow-up.

You

You: Weight limits?

Interviewer

Interviewer: Follow-up.

You

You: Do I control the motors directly, or send commands to a controller?

Interviewer

Interviewer: Assume a simple model: a lift moves one floor per time step, and doors stay open for a couple of steps.

QuestionWhat the answer decided
Floors and lifts, configurableElevatorSystem(minFloor, maxFloor, carCount, …)
Up/down buttons outside, floor buttons insideTwo kinds of request: hall calls (floor + direction) and car calls (car + floor)
The system decides which liftA dispatcher: a separate decision with its own class
Average wait, but nobody stuckA scheduling rule that’s fair, and we’ll measure both average and worst
MaintenanceA car can stop accepting calls, and its calls must move to another car
Fire alarmA special mode that overrides normal work (follow-up)
One floor per stepA tick-based model: easy to simulate and test

Functional requirements

  1. FR1. Each floor has up and down buttons (only up on the bottom floor, only down on the top). Pressing one creates a hall call.
  2. FR2. Each car has a button per floor. Pressing one creates a car call for that car.
  3. FR3. The system assigns every hall call to one car.
  4. FR4. Each car serves its calls in a sensible order, opening its doors at each stop.
  5. FR5. A hall call’s light goes off when any car arrives at that floor going that way.
  6. FR6. A car can be put into maintenance. It finishes the rides of people inside, hands its waiting hall calls to other cars, and then stops.
  7. FR7. The system can report each car’s floor, direction and state.

Non-functional requirements

  1. NFR1: Fair. Short average waits, and no call waits forever.
  2. NFR2: Safe. A car never moves with its doors open, and never skips past a floor in one step.
  3. NFR3: Correct under concurrency. Buttons are pressed on many threads at once, while cars are moving.
  4. NFR4: Swappable rules. The dispatching rule can change (rush-hour mode, energy saving) without changing the cars.
  5. NFR5: Testable. Time is simulated in ticks, so tests can run a whole morning in milliseconds.

Assumptions we’ll write down

AssumptionWhy it’s reasonable
A car moves one floor per tick; doors stay open 2 ticksA simple, deterministic model the interviewer agreed to
Pressing a lit button again does nothingReal buttons work this way
No weight limit in the main designFollow-up
All cars serve all floorsExpress lifts are a follow-up
Cars start at the ground floorAny start works

Step 2Break it into entities

Pull out the nouns

Building, floor, lift (car), button, up button, down button, floor button, door, motor, direction, request, call, passenger, technician, controller, display.

NounOwn data?Own rules?Becomes
Building / controllerall cars, the request queuewhich car answers a hall callElevatorSystem
Lift (car)floor, direction, state, stopsnever move with doors open; serve stops in orderElevatorCar
Direction——Direction enum (UP, DOWN)
Car state——CarState enum
Hall callfloor, direction—HallPress record
Car callcar, floor—CarPress record
“Which car answers”—a rule that will changeDispatcher interface
Dooropen or shut, a timer—a state and a counter inside the car
Floor——an int
Button——nothing: pressing it is a method call
Passenger, technician——nothing: outside the system

Two traps here.

*The Floor class trap.* It’s tempting to create Floor objects holding their buttons. But a floor has no behaviour of its own; it’s just a number that buttons and cars refer to. A class would add objects to create and pass around without adding any rules.

*The Passenger trap. The system never knows how many people are waiting or where they’re going until they’re inside. It sees button presses. (In our simulation, passengers exist, but only in the test*, not in the system.)

The two decisions

Here’s the insight that shapes everything else. An elevator system makes two decisions that look similar but aren’t:

DispatchingScheduling
The questionWhich car should answer this floor’s button?Which of my stops do I go to next?
Who decidesthe buildingeach car, for itself
What it knowsall cars’ positions and loadsits own stops and direction
How oftenonce per hall callevery time the car moves
Will it change?yes: rush hour, energy saving, VIP floorsrarely: LOOK is good almost everywhere

Mixing them is the most common design mistake on this problem: one class that both picks cars and orders stops. Separating them gives us a Dispatcher (owned by the system, swappable) and scheduling logic inside ElevatorCar.

The scheduling trap: three ways one car can choose

Before classes, settle how one car picks its next stop. Here are three rules on the same seven requests, arriving over time:

One elevator, 7 requests arriving over time
longest wait 30
01234567890612182430364248time (one floor per tick)floor37 floors travelledfloor 1 waits 30

Serving requests strictly in the order they arrived makes the lift zig-zag across the building: 37 floors of travel. Dots mark when and where each request was made.

Serve requests

First come, first served goes to requests in the order they arrived. Fair in a naive sense, but the car zig-zags across the building and travels much further.

Nearest first always goes to the closest request. Travel drops sharply. But look at the far floor: if new requests keep appearing near the car, a far request can wait a very long time. This is called starvation.

LOOK keeps going in its current direction while there are requests ahead, then turns round. Travel stays low, and nobody waits more than about two sweeps of the building. It’s what real lifts do. (Its cousin SCAN goes all the way to the end of the building before turning; LOOK turns at the last request, which saves the empty trip.)

Choose LOOK for each car, and say why with the trade-off: “Nearest-first has lower average wait in some cases, but it can starve a floor. LOOK bounds the worst case.”

Hall calls have a direction

One more detail that most answers miss. A car going up from floor 2 to 9 passes floor 5. Someone on floor 5 has pressed down. Should the car stop?

No. They want to go down; the car is going up. If it stops, they either get in and ride up first (annoying) or don’t get in (a wasted stop). So the car only stops for hall calls in its own direction, plus all car calls. It collects the “down” call at floor 5 on its way back. The exception: if floor 5 is the last stop in the up direction, the car can stop there, turn round, and answer the down call immediately.

That’s why each car keeps three sets of stops: car calls, up calls and down calls.

Pull out the verbs

Use caseWho starts itSteps
UC1: Call a liftSomeone on a floorpress up/down → dispatcher picks a car → the car adds the call
UC2: Choose a floorSomeone insidepress a floor → that car adds a stop
UC3: MoveThe clock (each tick)each car: open doors if this floor is a stop, else move one floor by LOOK
UC4: ArriveA cardoors open → hall-call light off → listeners told
UC5: MaintenanceTechniciancar stops accepting; its hall calls move to other cars; finishes rides; stops

Give each job to the class that knows the most

JobInformation neededOwner
Which car should take a hall call?every car’s floor, direction and stopsDispatcher, given the cars by the system
Which stop next? Move or open doors?this car’s stops, floor, directionElevatorCar
Is this car on its way to that floor?its direction and floorElevatorCar (answered for the dispatcher)
Has this hall call already been assigned?all assignmentsElevatorSystem
Turn a lit button offwhich car was assignedElevatorSystem
Accept presses from any threada safe queueElevatorSystem

CRC cards

ClassResponsibilitiesCollaborators
ElevatorSystemtake presses from any thread; each tick, assign hall calls and move cars; track lit buttons; maintenanceElevatorCar, Dispatcher
ElevatorCarhold stops; LOOK; doors; never move with doors openCarListener
Dispatcher (+ implementations)choose a car for a hall callElevatorCar
CarListenerbe told when doors open—
HallPress, CarPress, Maintenancedescribe one request—

Find what will change

Will changeBecomes
How cars are chosen (rush hour, energy, VIP)Dispatcher interface
Who reacts to arrivals (floor displays, logs, a phone app)CarListener interface
New kinds of request (fire alarm, VIP key)new Request records
The scheduling inside a carnot an interface: LOOK, unless the interviewer asks

Step 3Top-down or bottom-up?

Top-down, from the buttons inward: a floor button calls system.pressHallButton(5, DOWN). The system needs to pick a car, so a Dispatcher appears. The dispatcher needs to compare cars, so each car must report its floor, direction and how busy it is. The chosen car needs to remember the call, so assignHallCall appears. To serve it, the car needs a tick() that moves or opens doors.

Bottom-up, for coding: Direction and CarState, then ElevatorCar (the tricky LOOK logic, tested on its own with one car), then dispatchers, then the system that ties them together.

The car is where the subtle bugs live: stopping for the wrong direction, turning too early, moving with doors open. Write it first and test it alone, with a single car, before any dispatching exists.

Step 4Design each class

ElevatorSystem: the building’s controller

Job: accept button presses from anywhere, and on each tick, assign hall calls and move every car.

KnowsDoesDoesn’t do
floor rangepressHallButton(floor, dir) from any threadpick a car (the dispatcher does)
all carspressCarButton(car, floor) from any threadorder a car’s stops (the car does)
the dispatchersetMaintenance(car, on)
an inbox queue of requeststick(): the controller thread only
which car each lit hall button is assigned to
hall calls no car could take yet

Notice the split between “any thread” methods and tick(). The press methods only validate and enqueue. All the real work happens in tick(), which runs on one controller thread. Step 10 explains why this makes concurrency simple.

ElevatorCar: one lift

Job: serve its own stops in LOOK order, and keep itself safe.

KnowsDoesDoesn’t do
id, floor rangeaddCarStop(floor)decide which hall calls it gets
current floor, direction, stateassignHallCall(floor, dir), cancelHallCalltalk to other cars
car stops, up calls, down callstick(): open doors here, or move one floor
door timerstartMaintenance(), endMaintenance()
a draining flag (finishing before maintenance)report: busy(), stopCount(), farthestAhead()

Why three sets and not one list of stops? Because of the direction rule: the car must know whether a stop at floor 5 is “someone inside wants floor 5” (always stop), “someone at 5 wants up” or “someone at 5 wants down”. Each is a TreeSet, which keeps floors sorted, so “is there any stop above me?” is a quick lookup instead of a scan.

CarState: why not a State pattern here?

After the vending machine, it’s natural to reach for State classes. But compare the two problems. The vending machine has five events, each meaning something different in each of four states. The car has essentially one event, tick(), and its behaviour per state is a few lines. A switch on an enum is clearer here than four classes.

Saying this is a strong signal: patterns are tools for specific problems, not decorations. If the car later gains many events (fire mode, independent service, door-hold button, overload), that’s the moment to move to State classes.

Dispatcher: the swappable rule

Job: given the cars and a hall call, pick a car, or none if no car can take it.

Two implementations:

  • *NearestCarDispatcher* estimates how long each car would take to reach the call, and picks the cheapest. An idle car costs its distance. A car already heading that way, with the floor ahead of it, costs its distance. Any other car must first finish its current run, then come back. Each stop it already has adds a little, because each stop means doors opening and closing.
  • *RoundRobinDispatcher* gives calls to cars in turn. It’s a naive baseline, useful for showing that the cost-based rule actually helps.

Requests and listeners

HallPress(floor, dir), CarPress(carId, floor) and Maintenance(carId, on) are small records that travel through the queue. CarListener.doorsOpened(car, floor, leavingDirection) lets floor displays, logs, or a simulation react to arrivals.

Step 5How the classes relate

RelationshipIn this designWhy that kind
Compositionsystem owns its carscars are created by the system and only make sense inside it
Associationsystem → Dispatchergiven from outside; swappable
RealizationNearestCarDispatcher, RoundRobinDispatcher implement Dispatchertwo rules, one contract
RealizationHallPress, CarPress, Maintenance implement Requesta closed family of messages
Associationcar → CarListenertold about arrivals
Dependencydispatcher uses cars passed as a parameterit holds no references between calls

Request is a sealed interface: it lists exactly which records can implement it. That lets the system’s switch over requests be checked by the compiler. Add a new request type, and every switch that doesn’t handle it stops compiling, which is exactly what you want.

Step 6The class diagram, built up

Version 1: the building and its cars

ElevatorSystem- cars: List- inbox: Queue+ pressHallButton(floor, dir): void+ pressCarButton(car, floor): void+ tick(): voidElevatorCar- floor: int- direction: Direction- state: CarState- carStops: TreeSet- upCalls: TreeSet- downCalls: TreeSet+ addCarStop(floor): void+ assignHallCall(floor, dir): void+ tick(): void1..*

Version 2: the two decisions

ElevatorSystem- dispatcher: Dispatcher+ tick(): void«interface»Dispatcher+ choose(cars, floor, dir): OptionalNearestCarDispatcher+ cost(car, floor, dir): intRoundRobinDispatcher- next: intElevatorCar+ busy(): boolean+ farthestAhead(): int+ tick(): void1..*

Version 3: the whole picture

ElevatorSystem- cars: List- dispatcher: Dispatcher- inbox: Queue- assigned: Map+ pressHallButton(floor, dir): void+ pressCarButton(car, floor): void+ setMaintenance(car, on): void+ tick(): void«interface»Dispatcher+ choose(cars, floor, dir)NearestCarDispatcher«interface»RequestHallPress- floor: int- dir: DirectionCarPress- carId: int- floor: intElevatorCar- floor: int- direction: Direction- state: CarState- carStops, upCalls, downCalls- doorTicks: int+ addCarStop(floor): void+ assignHallCall(floor, dir): void+ tick(): void«interface»CarListener+ doorsOpened(car, floor, dir): void«enum»CarStateIDLEMOVINGDOORS_OPENOUT_OF_SERVICE1..*queuestells

Check it against the requirements

RequirementWhere it lives
FR1: hall buttons, none off the endspressHallButton validation; HallPress
FR2: car buttonspressCarButton → ElevatorCar.addCarStop
FR3: assign hall callsElevatorSystem.assign → Dispatcher.choose
FR4: sensible orderElevatorCar.tick with LOOK and the direction rule
FR5: lights off on arrivalassigned map, cleared in opened
FR6: maintenancestartMaintenance hands calls back; draining; OUT_OF_SERVICE
FR7: report statusfloor(), direction(), state()
NFR1: fairLOOK bounds waits; measured in step 11
NFR2: safemovement only in the IDLE/MOVING branch; one floor per tick; tested
NFR3: concurrencyinbox queue + single controller (step 10)
NFR4: swappable rulesDispatcher interface
NFR5: testableticks, not real time

Step 7The patterns, and why each one is here

PatternWhereProblem it solved
StrategyDispatcherthe rule for picking a car changes by time of day and building
ObserverCarListenerdisplays, logs and apps react to arrivals without the car knowing them
Command queueRequest records in inboxpresses from any thread become messages handled in order by one thread
FacadeElevatorSystembuttons and panels talk to one object, not to cars and dispatchers
Simulation clocktick()time is a method call, so tests run instantly and repeat exactly

The command queue deserves a moment. Each press becomes a small object describing what happened. Instead of every button thread reaching into the cars (and needing locks everywhere), they all drop messages into one queue, and one thread acts on them. This is the same idea as an event loop in a browser or a game. Step 10 shows why it’s the right fit here.

And the pattern deliberately not used: State classes for the car, explained in step 4.

Why the dispatcher doesn’t schedule

A natural question: why not let the dispatcher also tell each car its stop order? Because the car’s order depends on things that change every tick (its floor, new car calls from people inside), and recomputing a global plan every tick is far more complex than “each car runs LOOK on its own stops”. The dispatcher makes one decision per hall call; the car makes one per tick. Each is simple, and they don’t interfere.

Step 8Walk the flows

A hall call, from button to doors

ButtonElevatorSystemDispatcherElevatorCarDisplay1 pressHallButton(6, DOWN)2 inbox.add(HallPress)3 tick(): read inbox4 choose(cars, 6, DOWN)5 car 06 assignHallCall(6, DOWN)7 tick() … tick()8 doorsOpened(car 0, 6, DOWN)9 "6DOWN" light off10 doorsOpened(…)
  1. The button thread only adds a message to the inbox and returns. It never touches a car.
  2. On the next tick, the controller reads the message. If “6 DOWN” is already lit (assigned), it ignores the duplicate.
  3. The dispatcher picks a car. The system records the assignment, so the light stays on.
  4. Over the following ticks, the car moves by LOOK. When it opens its doors at 6, leaving downwards, the system turns the light off and tells the listeners.

One car, a short story

A single car waits at floor 0. Someone on floor 6 presses down, then someone on floor 3 presses up. The car sets off, and after two ticks someone already inside presses 8. Here’s the real run:

Text
t3: doors open at 3 (UP)
t11: doors open at 8 (UP)
t16: doors open at 6 (DOWN)

The car passed floor 6 on its way up without stopping, because that call was for going down. It went on to 8 (the car call), found nothing further up, turned round, and collected the down call at 6 on the way back. That’s LOOK plus the direction rule, working together.

Passing a call, then coming back for it

One car at 0, going to 9. Calls: floor 4 going down, floor 6 going up.

Text
stops in order: [0U, 6U, 9U, 4D]

Up from 0: stops at 6 (up call, same direction), skips 4 (down call), reaches 9, turns, and stops at 4 on the way down.

How the dispatcher chose

Two cars: car 0 idle at floor 0, car 1 at floor 6 going up to 12. The real cost numbers:

Text
car0 at 0, car1 at 6 going UP: call 8 UP costs car0=8, car1=4; call 2 UP costs car0=2, car1=18

A call at 8 going up is on car 1’s way: 2 floors, plus 2 for its one existing stop, so 4. Car 0 would need 8. Car 1 wins. A call at 2 going up is behind car 1, which would have to go up to 12 and come back: 6 + 10 + 2 = 18. Car 0, idle and two floors away, wins easily.

What walking the flows taught us

Found while walkingConsequence
A car going up shouldn’t stop for a down callstops are split by direction; the turnaround floor is the exception
Two cars might both reach a lit flooron arrival, the light goes off and the other car’s copy of the call is cancelled
A duplicate press must not assign a second carthe system remembers which hall calls are lit
Nobody may be waiting when a car goes into maintenanceits hall calls are handed back and reassigned
No car may be able to take a call (all in maintenance)the call waits in a list and is retried each tick

The life of a car

StateOn tickNext
Idleno stops: stay. Stops: serve here, or start movingIdle, Doors open or Moving
Movingstop here? open doors. Otherwise move one floorMoving or Doors open
Doors opencount down; when done, move on or go idleDoors open, Moving, Idle, or Out of service if draining
Out of servicenothingIdle when maintenance ends

Moving happens in exactly one place, the Idle/Moving branch. There’s no code path that changes the floor while the doors are open. That’s NFR2, guaranteed by the shape of the code rather than by a check that could be forgotten.

When things go wrong

SituationWhat happens
“Up” pressed on the top floorrejected straight away
A car button for a floor that doesn’t existrejected
The same hall button pressed 50 timesassigned once; the rest are ignored
Every car is in maintenancethe call waits and is assigned when a car returns
A car is taken out of service with callsits hall calls move to other cars; people inside are still delivered

Step 9Write the code, bottom-up

All of this compiles and runs; every output on this page comes from it.

9.1 Directions, states and listeners

Java
enum Direction {
    UP, DOWN;
    Direction opposite() { return this == UP ? DOWN : UP; }
}

enum CarState { IDLE, MOVING, DOORS_OPEN, OUT_OF_SERVICE }

interface CarListener { void doorsOpened(ElevatorCar car, int floor, Direction leaving); }

9.2 ElevatorCar: fields and requests

Java
final class ElevatorCar {
    static final int DOOR_TICKS = 2;
    private final int id, minFloor, maxFloor;
    private final CarListener listener;
    private final TreeSet carStops = new TreeSet<>(); 1
    private final TreeSet upCalls = new TreeSet<>(); 2
    private final TreeSet downCalls = new TreeSet<>();
    private int floor;
    private Direction dir = Direction.UP;
    private CarState state = CarState.IDLE;
    private int doorTicks;
    private boolean draining;
    private int travelled;

    ElevatorCar(int id, int minFloor, int maxFloor, int startFloor,
                CarListener listener) {
        this.id = id; this.minFloor = minFloor; this.maxFloor = maxFloor;
        this.floor = startFloor; this.listener = listener;
    }

    void addCarStop(int f) {
        if (f < minFloor || f > maxFloor)
            throw new IllegalArgumentException("no floor " + f);
        if (state == CarState.OUT_OF_SERVICE) return;
        if (f == floor && state == CarState.DOORS_OPEN) return; 3
        carStops.add(f);
    }

    private TreeSet calls(Direction d) {
        return d == Direction.UP ? upCalls : downCalls;
    }
    void assignHallCall(int f, Direction d) { calls(d).add(f); }
    void cancelHallCall(int f, Direction d) { calls(d).remove(f); }

    boolean accepting() { return state != CarState.OUT_OF_SERVICE && !draining; }
    boolean busy() {
        return !carStops.isEmpty() || !upCalls.isEmpty() || !downCalls.isEmpty();
    }
    int stopCount() { return carStops.size() + upCalls.size() + downCalls.size(); }
  1. 1pressed inside
  2. 2floor buttons, going up
  3. 3already here, doors open

9.3 ElevatorCar: one tick

Java
    void tick() {
        switch (state) {
            case OUT_OF_SERVICE -> { }
            case DOORS_OPEN -> {
                if (--doorTicks > 0) return; 1
                state = busy() ? CarState.MOVING
                      : draining ? CarState.OUT_OF_SERVICE : CarState.IDLE;
            }
            case IDLE, MOVING -> {
                if (!busy()) {
                    state = draining ? CarState.OUT_OF_SERVICE : CarState.IDLE;
                    return;
                }
                if (serveHere()) return; 2
                if (!anyAhead(dir)) dir = dir.opposite(); 3
                floor += dir == Direction.UP ? 1 : -1; 4
                travelled++;
                state = CarState.MOVING;
            }
        }
    }
  1. 1still open
  2. 2open doors instead of moving
  3. 3LOOK: turn at the last stop
  4. 4the only line that moves

This method is the whole safety story. The floor changes on exactly one line, note (4), and that line is only reachable when the state is Idle or Moving, never Doors open. And it changes by exactly one.

9.4 ElevatorCar: should I stop here?

Java
    private boolean serveHere() {
        boolean turning = !anyAhead(dir); 1
        boolean stop = carStops.contains(floor)
            || calls(dir).contains(floor)
            || (turning && (upCalls.contains(floor) || downCalls.contains(floor)));
        if (!stop) return false;
        carStops.remove(floor);
        boolean sameWay = calls(dir).remove(floor);
        if (!sameWay && turning && calls(dir.opposite()).remove(floor)) {
            dir = dir.opposite(); 2
        }
        state = CarState.DOORS_OPEN;
        doorTicks = DOOR_TICKS;
        listener.doorsOpened(this, floor, dir);
        return true;
    }

    private boolean anyAhead(Direction d) {
        for (TreeSet s : List.of(carStops, upCalls, downCalls)) {
            Integer next = d == Direction.UP ? s.higher(floor) : s.lower(floor);   // (3)
            if (next != null) return true;
        }
        return false;
    }
  1. 1nothing further this way
  2. 2answer the other button

Read the stop condition as three plain rules: stop for anyone inside who wants this floor; stop for people here going my way; and if this is as far as I go, stop for people here going either way. Note (2) handles the turnaround: if we stopped for a call in the other direction, the car now leaves in that direction, and the listener is told which way it’s leaving, so the right people get in.

Note (3) is why the stops are TreeSets: higher(floor) finds the next stop above in O(log n), without scanning.

9.5 ElevatorCar: maintenance and the dispatcher’s questions

Java
    // Hand back hall calls so another car can take them; finish car stops, then stop.
    List startMaintenance() {
        draining = true;
        List back = new ArrayList<>();
        for (int f : upCalls) back.add(new HallPress(f, Direction.UP));
        for (int f : downCalls) back.add(new HallPress(f, Direction.DOWN));
        upCalls.clear();
        downCalls.clear();
        return back;
    }

    void endMaintenance() {
        draining = false;
        if (state == CarState.OUT_OF_SERVICE) state = CarState.IDLE;
    }

    // the farthest stop in the current direction, or the current floor if none
    int farthestAhead() {
        Integer far = null;
        for (TreeSet s : List.of(carStops, upCalls, downCalls)) {
            if (s.isEmpty()) continue;
            int f = dir == Direction.UP ? s.last() : s.first();
            if (far == null || (dir == Direction.UP ? f > far : f < far)) far = f;
        }
        return far == null ? floor : far;
    }

    int id() { return id; }
    int floor() { return floor; }
    Direction direction() { return dir; }
    CarState state() { return state; }
    int travelled() { return travelled; }
}

Maintenance doesn’t stop the car instantly. People inside still get to their floors (the car stops are kept); only the hall calls, which another car can answer, are handed back. When the car runs out of work, it goes out of service by itself.

9.6 The dispatchers

Java
interface Dispatcher {
    Optional choose(List cars, int floor, Direction d);
}

final class NearestCarDispatcher implements Dispatcher {
    public Optional choose(List cars, int floor, Direction d) {
        return cars.stream()
            .filter(ElevatorCar::accepting)
            .min(Comparator.comparingInt(c -> cost(c, floor, d)));
    }

    static int cost(ElevatorCar c, int floor, Direction d) {
        int here = c.floor();
        if (!c.busy()) return Math.abs(here - floor); 1
        boolean onTheWay = c.direction() == d
            && (d == Direction.UP ? floor >= here : floor <= here);
        int far = c.farthestAhead();
        int distance = onTheWay ? Math.abs(floor - here) 2
            : Math.abs(far - here) + Math.abs(far - floor); 3
        return distance + 2 * c.stopCount(); 4
    }
}

final class RoundRobinDispatcher implements Dispatcher {
    private int next;
    public Optional choose(List cars, int floor, Direction d) {
        for (int i = 0; i < cars.size(); i++) {
            ElevatorCar c = cars.get((next + i) % cars.size());
            if (c.accepting()) {
                next = (next + i + 1) % cars.size();
                return Optional.of(c);
            }
        }
        return Optional.empty();
    }
}
  1. 1idle: just the distance
  2. 2heading there already
  3. 3finish the run, come back
  4. 4each stop costs door time

The cost is an estimate, not an exact prediction. Exact arrival times would depend on future presses nobody knows about yet. A simple, explainable estimate is what you want in an interview, and as step 11 shows, it already makes a large difference.

9.7 Requests

Java
sealed interface Request permits HallPress, CarPress, Maintenance { }
record HallPress(int floor, Direction dir) implements Request { }
record CarPress(int carId, int floor) implements Request { }
record Maintenance(int carId, boolean on) implements Request { }

9.8 ElevatorSystem

Java
final class ElevatorSystem {
    private final int minFloor, maxFloor;
    private final List cars = new ArrayList<>();
    private final Dispatcher dispatcher;
    private final Queue inbox = new ConcurrentLinkedQueue<>(); 1
    private final Map assigned = new HashMap<>(); 2
    private final Deque waiting = new ArrayDeque<>(); 3
    private final List listeners = new CopyOnWriteArrayList<>();
    private long now;

    ElevatorSystem(int minFloor, int maxFloor, int carCount, Dispatcher dispatcher) {
        this.minFloor = minFloor; this.maxFloor = maxFloor; this.dispatcher = dispatcher;
        for (int i = 0; i < carCount; i++)
            cars.add(new ElevatorCar(i, minFloor, maxFloor, minFloor, this::opened));
    }

    // any thread: buttons just leave a message
    public void pressHallButton(int floor, Direction d) {
        if (floor < minFloor || floor > maxFloor)
            throw new IllegalArgumentException("no floor " + floor);
        boolean offTheEnd = (floor == maxFloor && d == Direction.UP)
                         || (floor == minFloor && d == Direction.DOWN);
        if (offTheEnd)
            throw new IllegalArgumentException("no " + d + " button on floor " + floor);
        inbox.add(new HallPress(floor, d));
    }
    public void pressCarButton(int carId, int floor) {
        inbox.add(new CarPress(carId, floor));
    }
    public void setMaintenance(int carId, boolean on) {
        inbox.add(new Maintenance(carId, on));
    }
    public void addListener(CarListener l) { listeners.add(l); }

    // the controller thread only: one step of the whole building
    public void tick() {
        Request r;
        while ((r = inbox.poll()) != null) handle(r);
        for (int i = waiting.size(); i > 0; i--) assign(waiting.poll()); 4
        for (ElevatorCar c : cars) c.tick();
        now++;
    }

    private void handle(Request r) {
        switch (r) { 5
            case HallPress h -> assign(h);
            case CarPress c -> cars.get(c.carId()).addCarStop(c.floor());
            case Maintenance m -> {
                ElevatorCar car = cars.get(m.carId());
                if (!m.on()) { car.endMaintenance(); return; }
                for (HallPress h : car.startMaintenance()) {
                    assigned.remove(key(h.floor(), h.dir()));
                    assign(h);
                }
            }
        }
    }

    private void assign(HallPress h) {
        if (assigned.containsKey(key(h.floor(), h.dir()))) return; 6
        Optional car = dispatcher.choose(cars, h.floor(), h.dir());
        if (car.isEmpty()) { waiting.add(h); return; }
        car.get().assignHallCall(h.floor(), h.dir());
        assigned.put(key(h.floor(), h.dir()), car.get());
    }

    private void opened(ElevatorCar car, int floor, Direction leaving) {
        ElevatorCar owner = assigned.remove(key(floor, leaving)); 7
        if (owner != null && owner != car) owner.cancelHallCall(floor, leaving);
        for (CarListener l : listeners) l.doorsOpened(car, floor, leaving);
    }

    private static String key(int floor, Direction d) { return floor + d.name(); }
    List cars() { return cars; }
    long now() { return now; }
}
  1. 1the only shared thing
  2. 2lit: “5UP” → car
  3. 3no car free yet
  4. 4retry stuck calls
  5. 5sealed: all cases
  6. 6already lit
  7. 7the light goes off

Note (7) handles a case the flows revealed: car 1 was assigned “5 UP”, but car 0 happened to stop at floor 5 going up first (for someone inside). The people at 5 get into car 0, the light goes off, and car 1’s now-pointless stop is cancelled.

9.9 Maintenance in action

Two cars. A down call at floor 5 is assigned to car 0, and then car 0 is taken out of service:

Text
maintenance: [car 1 at 5], car0 state OUT_OF_SERVICE
top floor: no UP button on floor 9

Car 0 handed its call back, the dispatcher gave it to car 1, and car 1 answered it. Car 0, with nobody inside, went out of service.

Step 10Make it safe when buttons are pressed everywhere

Interviewer

Interviewer: Buttons are pressed on many threads, while the lifts are moving. How do you keep this safe?

You

You: There are two common designs. Let me compare them.

Option A: a lock everywhere

Give each car a lock and have button threads call into cars directly: car.addCarStop() takes the car’s lock. The dispatcher reads every car’s floor and stops, so it must lock all cars at once (always in the same order, to avoid deadlocks). The tick thread locks each car while moving it.

It can be made correct, but every method needs thought about who else might be running, and the dispatcher’s “lock all cars” step is the kind of code that hides deadlocks.

Option B: a queue and one controller thread

Button threads never touch cars. They validate the press and drop a message into a ConcurrentLinkedQueue, a queue built for many threads adding at once. One controller thread runs tick(): it empties the queue, assigns calls, and moves every car. Since only that thread ever touches cars, assignments and the lit-button map, none of them need locks at all.

Locks everywhereQueue + controller
Locks in carsyes, every methodnone
Deadlock riskreal (dispatcher locks many cars)none
Order of eventsdepends on thread timingexactly the order they were read
Testingneeds threadscall tick() in a loop
Cost—a press waits up to one tick before it’s acted on

Option B is the better fit, because the building physically moves in steps anyway; nobody notices a fraction of a tick’s delay before a button is handled. It’s the same design games and user interfaces use: many inputs, one loop that updates the world.

Prove it

Eight threads press 16,000 random hall and car buttons as fast as they can, while the controller ticks. After every tick, a checker compares each car’s floor before and after: did any car move while its doors were open, or jump more than one floor? At the end: was every lit button answered?

Text
concurrent: 16000 presses, every lit button answered=true,
 moved with doors open or skipped a floor=false, all idle=true

Step 11Follow-ups, with real numbers

Does the dispatcher actually help?

Interviewer

Interviewer: Is your cost-based dispatcher better than just taking turns? How do you know?

Don’t guess: simulate. We ran 300 passengers through a 20-floor building with three cars, one arriving every two ticks. In the morning rush, 80% start at the ground floor going up. In mixed traffic, 20% do and the rest travel between random floors. The same passengers, in the same order, for both dispatchers:

TrafficDispatcherAverage waitLongest waitFloors travelled
Morning rushNearest car10 ticks104791
Morning rushRound robin2894926
MixedNearest car2193803
MixedRound robin3192954

Cost-based dispatch cut the average wait by almost two-thirds in the rush and by a third in mixed traffic, while travelling 15% less. But look at the longest wait column: it’s slightly worse. Choosing the cheapest car for every call is greedy; occasionally one unlucky call keeps losing to cheaper ones. That’s the same trade-off as nearest-first scheduling, one level up.

Saying this out loud is far stronger than claiming the dispatcher is simply better. The fix, if the interviewer cares about the worst case, is to add the call’s age to the cost, so a call that’s been waiting gets an ever stronger claim on the next car.

Morning rush mode

Interviewer

Interviewer: At 9am everyone starts at the lobby. Can you do better?

A new Dispatcher: between 8:30 and 10:00, send any idle car straight back to the ground floor instead of waiting where it stopped. Some buildings also split floors into zones (car A serves 1–7, car B serves 8–14), which cuts the number of stops per trip. Both are new strategy classes; the cars don’t change. A TimeOfDayDispatcher can hold the others and pick one by the clock. That’s a strategy that chooses strategies, and it’s still just a Dispatcher.

Fire alarm

Interviewer

Interviewer: When the fire alarm sounds, all lifts must go to the ground floor, open their doors, and stay there.

This is a new mode that overrides everything, so it deserves its own request and a new car state:

  • A FireAlarm record joins the sealed Request family. The compiler then points at every switch that must handle it.
  • On FireAlarm, the system clears all hall calls and lit buttons, and tells every car recall(groundFloor).
  • The car drops all stops, sets a single stop at the ground floor, and enters a new FIRE_RECALL state. In that state it ignores new car calls, and when it arrives it opens its doors and stays.

Now the car has several events with different meanings per state: tick, car calls, recall, maintenance. This is the moment, mentioned in step 4, when moving the car to the State pattern starts to pay off. Say so.

Weight limits

Interviewer

Interviewer: A lift can carry 8 people.

The car gets a load sensor reading. When full, it doesn’t stop for hall calls (car calls still count, so people can get out), and the dispatcher adds a large cost for full cars. If a full car passes a lit floor, the system must make sure another car still has that call: on arrival, only turn the light off if the doors actually opened for it. Our opened method already ties “light off” to “doors opened leaving that way”, so the change is small.

Destination dispatch

Interviewer

Interviewer: The new building has keypads in the lobby: you type your floor, and the screen tells you which lift to take.

Now hall calls carry a destination, not just a direction. The dispatcher can group people going to nearby floors into the same car, which is why these systems move more people per hour. The model changes: HallPress(floor, dir) becomes DestinationRequest(from, to), the dispatcher answers immediately (so the screen can say “Lift B”), and the car gets both stops at once. The car’s LOOK logic doesn’t change at all, which shows the scheduling and dispatching split was the right cut.

Saving energy

Interviewer

Interviewer: At night the building is nearly empty. Can lifts save power?

A night-mode dispatcher prefers cars that are already moving over idle ones (a moving car costs less extra energy than starting a parked one), and lets idle cars park and sleep. Another strategy class. If the interviewer pushes on how much energy, the honest answer is that it depends on the motors, and you’d measure it with the same kind of simulation as above, adding a cost per start and per floor.

Mistakes people make on this one

  • *One Elevator class that dispatches, schedules and moves,* with no separation of the two decisions.
  • First-come-first-served scheduling, or nearest-first without mentioning starvation.
  • Ignoring hall-call direction, so cars stop for people going the other way.
  • *A Floor class and a Passenger class* that hold no rules.
  • Button threads calling into cars directly with no plan for locking.
  • A dispatcher that locks every car in whatever order it finds them.
  • Moving the car in more than one place, so a doors-open check can be forgotten.
  • Maintenance that strands waiting people because their hall calls stay with the broken car.
  • Claiming an algorithm is “optimal” without saying optimal for what.

How this answer gets scored

MomentWhat earns creditWhat costs you
RequirementsHall vs car calls; asking what “good” meansTreating all buttons the same
StructureDispatching and scheduling as separate decisions and classesOne class doing everything
SchedulingLOOK, with the trade-off against FCFS and nearest-first“Go to the nearest request” with no caveat
DirectionStopping only for same-direction hall calls; the turnaround caseStopping for everyone
PatternsDispatcher as Strategy; listeners; requests as messages; why not State (yet)State classes for a one-event machine
SafetyMovement in exactly one place; never with doors openA check sprinkled in several places
ConcurrencyA queue and one controller, with the trade-off against locks“Make the methods synchronized”
Follow-upsMeasured comparisons, honest about worst cases; new strategies and requestsClaims without numbers; editing cars for every feature

The whole design on one page

  1. Two decisions: the system’s Dispatcher picks a car per hall call; each ElevatorCar picks its next stop.
  2. Scheduling: LOOK. Keep going while there’s work ahead, then turn round.
  3. Direction: three sets per car (car stops, up calls, down calls). Stop for car stops and same-direction calls; at the turnaround, either direction.
  4. Safety: the floor changes on one line, only when doors are shut, by one floor.
  5. Concurrency: buttons enqueue Request messages; one controller thread runs tick(). No locks in cars.
  6. Dispatching: a cost estimate (distance, on-the-way, existing stops). Measured: average wait from 28 to 10 in the rush, worst wait slightly worse. Add call age to fix that.
  7. Follow-ups: new dispatchers (rush hour, energy), new requests (fire), new states when the car gains enough events.