Skip to content

Low Level DesignWorked problems

Design an elevator system

Design an elevator system step by step for a low level design interview. Grow from one car to a sweep that never starves a floor, many cars with a dispatcher, broken and full cars, and a single control loop, with code in Java, Python, C#, Go and TypeScript.

Senior30 min core · 47 min full depth

“Design an elevator system for a building.” Usually followed, ten minutes in, by “now there are four elevators”.

The elevator question hides two different problems under one prompt. The first is inside one car: in what order should it visit the floors it has to stop at? The second is across cars: when someone presses a button on floor 12, which car should go?

Most answers mix the two into one big ElevatorController with an endless loop and a sleep. Then they can’t explain why a person on floor 19 waits forever during the lunch rush.

If you only have a minute, this is the design. The rest of the page explains how we get here.

  1. Time is a method, not a sleeping thread. Each call to step() moves the system forward one tick. A test can run an hour of traffic in a few microseconds.
  2. Each car sweeps. It keeps going in one direction while it has stops ahead, then turns around. This is called LOOK, and it means nobody waits more than about two trips across the building.
  3. A button on a floor carries a direction. “Down on floor 5” is only served by a car going down. That’s why there are two buttons.
  4. A dispatcher chooses the car. It picks the car that would arrive soonest, counting the detour a car must make if it’s heading the wrong way. The rule can be swapped per building.
  5. The building remembers every call it accepted. So a broken car hands its calls to other cars, and pressing a lit button again does nothing. And one control loop owns everything, so button presses from many threads can’t corrupt it.

We’ll build this in four stages, then deal with buttons pressed from many threads at once.

Before drawing anything, we ask a few questions. Each answer changes a class. Anything that doesn’t, we assume and move on.

Interviewerminute 0

Design an elevator system for a building.

What they are testing

Whether you pin down the shape (floors, cars, up and down buttons or a keypad) in about three minutes, and ask what “good” means: average wait, worst wait, or energy. Jumping straight to classes means you’ll improve something nobody asked for.

Here are the questions that matter:

  • How many floors and cars? Tens of floors, a handful of cars. With one car, there’s no dispatcher. With hundreds of floors, splitting the building into zones becomes worth it.
  • Up and down buttons, or a keypad where riders type their floor? Up and down buttons on each floor. A keypad is an extension at the end.
  • What are we improving? First, nobody waits forever. Then, short average waits. If only the average mattered, “nearest stop first” would win, and it can leave some floors waiting forever.
  • Do we control the motors? No. We decide where cars go. Motors and doors are hardware, so we don’t model acceleration or door timing.
  • Can cars break or fill up? Yes. A car can be taken out of service, and a sensor says when a car is full. Perfect cars would remove stage 4.

These answers give us one promise that matters more than the others: no call waits forever, whatever the traffic. It’s decided by the order of stops inside each car. Three smaller promises come with it. Button presses from many threads at once never corrupt a car. The rule for choosing a car can change without editing the cars. And time is passed in, so a test can check every tick.

We leave out motor control, door timing in seconds, and the alarm and intercom.

Go deeperThree more questions, and what each changes1 min
Question We assume If the answer were different
Do all cars serve all floors? Yes Express or zoned cars filter which cars the dispatcher can pick
Special modes: fire service, VIP? Mentioned, not built Each mode changes several behaviors at once; that’s where State pays off
Morning and evening rush hours? One dispatching rule for now Rush-hour rules are a second dispatcher, switched by time of day

We’ll build the design in four stages. Each stage adds only the classes that a new requirement needs. In each diagram, the new parts are outlined, and the colors show what each class does (the key is under every diagram).

Interviewerminute 4

Start with one elevator. Someone presses a button. What happens?

What they are testing

Whether you make time explicit (a step that moves forward one tick) instead of a thread that sleeps, and say why. while (true) { move(); sleep(1000); } is the answer they hear most, and it can’t be tested without waiting in real time.

Start with the smallest thing that works: one Car, with a floor, a door that’s open or closed, and a queue of requested floors.

The important decision is about time. Elevators move on a clock. The tempting model is a thread that moves the car and then sleeps for a second. But that ties every test to the real clock. Checking a ten-floor trip takes ten seconds, and checking two things happening at once takes luck.

So instead, time is a method. Each call to step() is one tick, and a tick does exactly one thing: close the doors, open them at a requested floor, or move one floor. Whoever owns the car decides how often to call it. In production, that’s a loop running every half second. In a test, it’s a for loop that runs a whole morning rush in microseconds.

Stage 1: one car and a queue of floors

Car

-floor: int

-doorsOpen: bool

-requests: Queue<int>

+request(floor)

+step()

Car

-floor: int

-doorsOpen: bool

-requests: Queue<int>

+request(floor)

+step()

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Car.javacar.pyCar.cscar.gocar.ts
// One car in one shaft. Time moves only when someone calls step(), one tick at a time,
// so a test can run a whole morning rush in microseconds and check every tick.
class Car {
private int floor; // floors are numbered from 0
private boolean doorsOpen;
// flaw 1: first come, first served. Requests for 9, 1, 8, 2 send the car up and down
// the whole shaft four times, and it passes floor 2 twice without stopping.
private final Deque<Integer> requests = new ArrayDeque<>();
// flaw 2: a request is only a floor. Someone on 5 who wants to go down is picked up by a
// car on its way up, and rides to the top first. Stage 2 gives hall calls a direction.
void request(int f) {
if (!requests.contains(f)) requests.addLast(f); // pressing twice is one request
}
// One tick does exactly one thing: close the doors, open them at a requested floor,
// or move one floor toward the next request.
void step() {
if (doorsOpen) { doorsOpen = false; return; }
if (requests.isEmpty()) return;
int target = requests.peekFirst();
if (target == floor) { requests.removeFirst(); doorsOpen = true; return; }
floor += target > floor ? 1 : -1;
}
int floor() { return floor; }
boolean doorsOpen() { return doorsOpen; }
}

It works, but it has two problems, and we leave them in on purpose. Floors are served in the order they were pressed. So presses for 9, 1, 8 and 2 send the car up and down the whole building four times, passing floor 2 twice. And a request is just a floor number. So someone on floor 5 who wants to go down gets picked up by a car on its way to the top.

Interviewerminute 8

Your car zig-zags. In what order should it serve its stops?

What they are testing

Whether you know the sweep (keep going while there’s work ahead, then turn around) and can say why “nearest first” is wrong: it can leave floors waiting forever. Naming SCAN or LOOK is a plus. Explaining why nearest-first is unfair is what they’re really listening for.

Disk drives had the same problem in the 1970s: a reading head moving along a line, with requests arriving at points on it. The options are well known:

Order How it picks the next stop Can a floor wait forever? Notes
First come, first served Press order No, but it wastes travel Stage 1
Nearest first The closest stop, either way Yes: far floors can starve Looks best, is unfair
SCAN Sweep end to end, turn around at the last floor No Travels to the end even if nobody is there
LOOK (our choice) Sweep while stops remain ahead, then turn around No: at most two sweeps What real elevators do

Why does “nearest first” fail? Picture steady traffic between floors 1 and 3. There’s always a stop close by, so the car never goes up to floor 19. The person there waits forever. That’s called starvation.

LOOK is the elevator algorithm. A car going up keeps going up while any stop remains above it, then turns around. A rider ahead of the car, in its direction, is served on this sweep. Anyone else is served on the next one. So no wait is longer than about two trips across the building.

Doing this right needs one more fact: a floor button has a direction. Pressing “down” on floor 5 is a request to be picked up by a car going down. So stops come in three kinds:

  • A car stop (a button inside the car) is served whichever way the car is moving.
  • An up call is served only by a car going up.
  • A down call is served only by a car going down.

There’s one exception: the top of a sweep. A car going up, with nothing above it, can take a down call right where it is, because down is where it goes next anyway. Each kind of stop is kept in a sorted set, so “is there anything above me?” is one quick lookup.

step() now also returns the floor calls it answered. The building uses that to switch off the button’s light, and later stages use it to know which calls are still waiting.

Stage 2: hall calls with a direction, and a car that sweeps

serves

Car

+id

-floor: int

-direction: Direction

-carStops: SortedSet<int>

-upCalls: SortedSet<int>

-downCalls: SortedSet<int>

+carCall(floor)

+hallCall(HallCall)

+step() : List<HallCall>

+highestStop() : int

+lowestStop() : int

«value»

HallCall

+floor

+direction

«enum»

Direction

UP

DOWN

IDLE

serves

Car

+id

-floor: int

-direction: Direction

-carStops: SortedSet<int>

-upCalls: SortedSet<int>

-downCalls: SortedSet<int>

+carCall(floor)

+hallCall(HallCall)

+step() : List<HallCall>

+highestStop() : int

+lowestStop() : int

«value»

HallCall

+floor

+direction

«enum»

Direction

UP

DOWN

IDLE

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Car.javacar.pyCar.cscar.gocar.ts
// Which way a car is heading. IDLE means it has nothing left to do.
enum Direction { UP, DOWN, IDLE }
// A button pressed on a floor. It is a promise to arrive *going that way*, which is
// why it carries a direction and a button inside the car does not.
record HallCall(int floor, Direction direction) {}
class Car {
final int id;
private final int floors; // floors are 0 .. floors - 1
private int floor;
private Direction direction = Direction.IDLE;
private boolean doorsOpen;
// Three kinds of stop, each a sorted set so "anything above me?" is one lookup.
// A car stop is served whichever way the car is going. An up call only by a car going up.
private final TreeSet<Integer> carStops = new TreeSet<>();
private final TreeSet<Integer> upCalls = new TreeSet<>();
private final TreeSet<Integer> downCalls = new TreeSet<>();
Car(int id, int floors) { this.id = id; this.floors = floors; }
void carCall(int f) { check(f); carStops.add(f); }
void hallCall(HallCall c) {
check(c.floor());
(c.direction() == Direction.UP ? upCalls : downCalls).add(c.floor());
}
// One tick: close the doors, or open them for the stops here, or move one floor.
// Returns the hall calls answered this tick, so the building can turn their lights off.
List<HallCall> step() {
if (doorsOpen) { doorsOpen = false; return List.of(); }
boolean carStop = carStops.remove(floor); // before serveHere, which checks what is left above
List<HallCall> served = serveHere();
if (carStop || !served.isEmpty()) { doorsOpen = true; return served; }
direction = nextDirection();
if (direction == Direction.UP) floor++;
else if (direction == Direction.DOWN) floor--;
return List.of();
}
// The hall calls this floor can answer, given the way the car is heading.
private List<HallCall> serveHere() {
List<HallCall> served = new ArrayList<>();
if (direction != Direction.DOWN && upCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.UP));
direction = Direction.UP;
} else if (direction != Direction.UP && downCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.DOWN));
direction = Direction.DOWN;
} else if (direction == Direction.UP && highestStop() <= floor && downCalls.remove(floor)) {
// The top of this sweep: nothing above, so the down call here is next anyway.
served.add(new HallCall(floor, Direction.DOWN));
direction = Direction.DOWN;
} else if (direction == Direction.DOWN && lowestStop() >= floor && upCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.UP));
direction = Direction.UP;
}
return served;
}
// LOOK: keep going while there is work ahead, then turn around. Every stop in the car's
// way is served on this sweep, and everything else on the next, so no call waits forever.
private Direction nextDirection() {
boolean above = highestStop() > floor, below = lowestStop() < floor;
if (direction == Direction.UP && above) return Direction.UP;
if (direction == Direction.DOWN && below) return Direction.DOWN;
if (above) return Direction.UP;
if (below) return Direction.DOWN;
return Direction.IDLE;
}
// The farthest stops in each direction. With no stops, both are the current floor.
int highestStop() { return max(max(max(floor, carStops), upCalls), downCalls); }
int lowestStop() { return min(min(min(floor, carStops), upCalls), downCalls); }
private static int max(int f, TreeSet<Integer> s) { return s.isEmpty() ? f : Math.max(f, s.last()); }
private static int min(int f, TreeSet<Integer> s) { return s.isEmpty() ? f : Math.min(f, s.first()); }
private void check(int f) {
if (f < 0 || f >= floors) throw new IllegalArgumentException("no floor " + f);
}
int floor() { return floor; }
Direction direction() { return direction; }
boolean doorsOpen() { return doorsOpen; }
}
Go deeperEverything one tick can do to a car1 min

Here is everything one tick can do to a car. Every tick is one arrow, so a test can drive the car along any path and check where it is after each step.

State: what one tick can do to a car

stop above

stop below

stop here

stops still above

stop here, going up or top of sweep

stops still below

stop here, going down or bottom of sweep

next tick, work above and heading up

next tick, work below and heading down

next tick, no stops

Idle

MovingUp

MovingDown

DoorsOpen

stop above

stop below

stop here

stops still above

stop here, going up or top of sweep

stops still below

stop here, going down or bottom of sweep

next tick, work above and heading up

next tick, work below and heading down

next tick, no stops

Idle

MovingUp

MovingDown

DoorsOpen

Scroll or pinch to zoom, drag to pan
  • Color key
  • State
  • Fails or stops

What does LOOK cost? It’s not the shortest total travel. A rider just behind the car waits for a full sweep, even if a quick turn would have served them. That’s the price of the promise that nobody waits forever, and it’s the right price.

Go deeperWhy does step() remove the car stop before checking floor calls?1 min

Because the turnaround check asks “is there anything above me?”. If the car stop for this floor is still in the set, the answer is “yes, this floor”, and a down call here gets skipped. The order of those two lines is exactly the kind of detail that the test “a down call at the top turns the car around” exists to protect.

Interviewerminute 15

Now there are four elevators. Someone presses “up” on floor 11. Which car goes?

What they are testing

Whether you separate “which car” (a dispatcher, swappable) from “in what order” (the car’s sweep, unchanged), and whether your choice counts direction. “The closest car” is the answer they’ll push back on, with a car one floor away heading the other way.

Choosing a car is a different job from moving one. And the right rule depends on the building, and even the time of day. So it becomes a Dispatcher interface, which is the Strategy pattern. An ElevatorSystem owns the cars, and asks the dispatcher whenever a floor button is pressed. A button inside a car needs no dispatcher: it goes straight to that car.

The first dispatcher sends the car that would arrive soonest, counted in floors of travel:

  • An idle car costs its distance to the caller.
  • A car already heading toward the caller, in the caller’s direction, costs its distance.
  • Any other car must finish its sweep and come back. It costs the distance to where it turns around, plus the distance from there back to the caller.

So a car one floor away, but heading up to the top, costs the whole detour. An idle car five floors away wins. This is an estimate: it ignores the stops along the way. That’s fine for choosing between cars, and it’s the first thing a better dispatcher would improve.

Stage 3: a dispatcher chooses, the system owns the cars

1

*

reads

creates

ElevatorSystem

-cars: List<Car>

-dispatcher: Dispatcher

+hallCall(floor, Direction) : Car

+carCall(carId, floor)

+step()

«interface»

Dispatcher

+choose(cars, HallCall) : Car

NearestCarDispatcher

+cost(Car, HallCall) : int

Car

+hallCall(HallCall)

+step() : List<HallCall>

+highestStop() : int

+lowestStop() : int

«value»

HallCall

1

*

reads

creates

ElevatorSystem

-cars: List<Car>

-dispatcher: Dispatcher

+hallCall(floor, Direction) : Car

+carCall(carId, floor)

+step()

«interface»

Dispatcher

+choose(cars, HallCall) : Car

NearestCarDispatcher

+cost(Car, HallCall) : int

Car

+hallCall(HallCall)

+step() : List<HallCall>

+highestStop() : int

+lowestStop() : int

«value»

HallCall

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
NearestCarDispatcher.javanearest_car_dispatcher.pyNearestCarDispatcher.cselevator_system.goelevator-system.ts
// Picks which car answers a hall call. The rule changes from building to building
// (and from morning to evening), so it is a strategy the system is given.
interface Dispatcher {
Car choose(List<Car> cars, HallCall call);
}
// Sends the car that would arrive soonest, counted in floors travelled. A car already
// heading toward the caller in the caller's direction is cheap; one that must finish its
// sweep and come back pays for the whole detour.
class NearestCarDispatcher implements Dispatcher {
public Car choose(List<Car> cars, HallCall call) {
// Ties go to the lower id, so the same situation always gets the same answer.
return cars.stream()
.min(Comparator.comparingInt((Car c) -> cost(c, call)).thenComparingInt(c -> c.id))
.orElseThrow();
}
static int cost(Car c, HallCall call) {
int at = c.floor(), f = call.floor();
return switch (c.direction()) {
case IDLE -> Math.abs(at - f);
case UP -> {
if (call.direction() == Direction.UP && f >= at) yield f - at; // on the way
int turn = Math.max(c.highestStop(), f); // up to the top, then back
yield (turn - at) + (turn - f);
}
case DOWN -> {
if (call.direction() == Direction.DOWN && f <= at) yield at - f;
int turn = Math.min(c.lowestStop(), f);
yield (at - turn) + (f - turn);
}
};
}
}
// The building's entry point. Buttons call it; it owns the cars and asks the dispatcher
// which car answers each hall call. A button inside a car goes straight to that car.
class ElevatorSystem {
private final List<Car> cars = new ArrayList<>();
private final Dispatcher dispatcher;
ElevatorSystem(int floors, int carCount, Dispatcher dispatcher) {
for (int i = 0; i < carCount; i++) cars.add(new Car(i, floors));
this.dispatcher = dispatcher;
}
// The choice is made once, when the button is pressed. Stage 4 revisits that when
// a car breaks down holding calls.
Car hallCall(int floor, Direction d) {
HallCall call = new HallCall(floor, d);
Car car = dispatcher.choose(cars, call);
car.hallCall(call);
return car;
}
void carCall(int carId, int floor) { cars.get(carId).carCall(floor); }
void step() { for (Car c : cars) c.step(); }
Car car(int id) { return cars.get(id); }
}
Go deeperOne call, from button to doors1 min

Here is one call from start to finish: the button is pressed, the system asks the dispatcher, the chosen car gets the call, and a later tick answers it and switches off the light.

Sequence: a hall call is dispatched and answered
Car 1 (idle on 6)Car 0 (floor 12, going up to 19)DispatcherElevatorSystemFloor 11 buttonCar 1 (idle on 6)Car 0 (floor 12, going up to 19)DispatcherElevatorSystemFloor 11 buttoncar 0 costs 7 + 8 = 15, car 1 costs 5later tickshallCall(11, UP)choose([car 0, car 1], 11 UP)car 1hallCall(11 UP)step() until it reaches 11answered 11 UP (light off)Car 1 (idle on 6)Car 0 (floor 12, going up to 19)DispatcherElevatorSystemFloor 11 buttonCar 1 (idle on 6)Car 0 (floor 12, going up to 19)DispatcherElevatorSystemFloor 11 buttoncar 0 costs 7 + 8 = 15, car 1 costs 5later tickshallCall(11, UP)choose([car 0, car 1], 11 UP)car 1hallCall(11 UP)step() until it reaches 11answered 11 UP (light off)
Scroll or pinch to zoom, drag to pan
  • Color key
  • Entity
  • Interface
  • Facade

The car is chosen once, when the button is pressed. That’s simple and predictable. Real buildings light a lantern over the chosen car’s doors right away, so riders walk to the right car, and that only works if the choice sticks. The cost: the choice can go stale. The chosen car might pick up five more stops and become slow. Stage 4 only reassigns a call when a car can’t serve it at all.

Go deeperWhy does the dispatcher read the cars, instead of each car working out its own cost?1 min

Because the cost is the dispatcher’s policy, not a fact about the car. A morning rush dispatcher might ignore direction and prefer cars at the lobby. An energy-saving one might prefer a car that’s already moving. If each car worked out its own cost, every policy change would edit Car. So the car shares what it knows (its floor, its direction, its farthest stops), and the dispatcher decides.

Interviewerminute 22

Car 2 breaks down with three floor calls assigned to it. And people keep pressing the button that’s already lit. What happens?

What they are testing

Whether the building keeps a record of every accepted call and which car holds it, which makes reassigning and repeated presses easy. Answers that only fix the dispatcher (“skip broken cars”) leave those three riders waiting forever.

Three requirements arrive together, and one record answers all of them. The system now keeps a map from every accepted floor call to the car that holds it. That map is exactly what the lit buttons mean.

  • A repeated press of a lit button finds the call already in the map, and does nothing.
  • A car taken out of service hands back the floor calls it held. The system removes them from the map and sends each one to the dispatcher again.
  • A full car keeps serving its riders, but the system stops offering it to the dispatcher. The calls it already holds stay with it, because some riders will get off before it gets there.

If no car can take a call (every car is full or broken), the call goes into a waiting queue. It’s offered again on every tick, oldest first. The dispatcher never sees broken or full cars: the system removes them from the list before asking, so no dispatcher has to remember to.

Stage 4: the system tracks every call it accepted

1

*

assigned and waiting

*

ElevatorSystem

-assigned: Map<HallCall,Car>

-waiting: Queue<HallCall>

+hallCall(floor, Direction)

+takeOutOfService(carId)

+setFull(carId, bool)

+isLit(HallCall) : bool

+step()

Car

-inService: bool

-full: bool

+accepting() : bool

+takeOutOfService() : List<HallCall>

+returnToService()

«interface»

Dispatcher

«value»

HallCall

1

*

assigned and waiting

*

ElevatorSystem

-assigned: Map<HallCall,Car>

-waiting: Queue<HallCall>

+hallCall(floor, Direction)

+takeOutOfService(carId)

+setFull(carId, bool)

+isLit(HallCall) : bool

+step()

Car

-inService: bool

-full: bool

+accepting() : bool

+takeOutOfService() : List<HallCall>

+returnToService()

«interface»

Dispatcher

«value»

HallCall

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
  • New in this step
Car.javacar.pyCar.cscar.gocar.ts
// One elevator car. It keeps its own stops and moves one tick at a time, sweeping up and down
// (the LOOK rule below). A full car or one out of service gets no new hall calls.
class Car {
final int id;
private final int floors; // floors are 0 .. floors - 1
private int floor;
private Direction direction = Direction.IDLE;
private boolean doorsOpen;
// Three kinds of stop, each a sorted set so "anything above me?" is one lookup.
// A car stop is served whichever way the car is going. An up call only by a car going up.
private final TreeSet<Integer> carStops = new TreeSet<>();
private final TreeSet<Integer> upCalls = new TreeSet<>();
private final TreeSet<Integer> downCalls = new TreeSet<>();
private boolean inService = true;
private boolean full; // from the load sensor
Car(int id, int floors) { this.id = id; this.floors = floors; }
// A full car still takes its riders where they asked, but shouldn't be sent for more.
boolean accepting() { return inService && !full; }
void setFull(boolean full) { this.full = full; }
// Stop here with the doors open, and hand back the hall calls this car was holding so
// the building can give them to another car. Without this, those people wait forever.
List<HallCall> takeOutOfService() {
inService = false;
doorsOpen = true;
direction = Direction.IDLE;
List<HallCall> orphans = new ArrayList<>();
for (int f : upCalls) orphans.add(new HallCall(f, Direction.UP));
for (int f : downCalls) orphans.add(new HallCall(f, Direction.DOWN));
upCalls.clear();
downCalls.clear();
carStops.clear(); // riders step out here; their buttons no longer mean anything
return orphans;
}
void returnToService() { inService = true; } // the doors close on the next tick
// A button inside the car: stop at f whichever way the car is going.
void carCall(int f) { check(f); carStops.add(f); }
// A hall call the building gave this car: stop at that floor going that way.
void hallCall(HallCall c) {
check(c.floor());
(c.direction() == Direction.UP ? upCalls : downCalls).add(c.floor());
}
// One tick: close the doors, or open them for the stops here, or move one floor.
// Returns the hall calls answered this tick, so the building can turn their lights off.
List<HallCall> step() {
if (!inService) return List.of();
if (doorsOpen) { doorsOpen = false; return List.of(); }
boolean carStop = carStops.remove(floor); // before serveHere, which checks what is left above
List<HallCall> served = serveHere();
if (carStop || !served.isEmpty()) { doorsOpen = true; return served; }
direction = nextDirection();
if (direction == Direction.UP) floor++;
else if (direction == Direction.DOWN) floor--;
return List.of();
}
// The hall calls this floor can answer, given the way the car is heading.
private List<HallCall> serveHere() {
List<HallCall> served = new ArrayList<>();
if (direction != Direction.DOWN && upCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.UP));
direction = Direction.UP;
} else if (direction != Direction.UP && downCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.DOWN));
direction = Direction.DOWN;
} else if (direction == Direction.UP && highestStop() <= floor && downCalls.remove(floor)) {
// The top of this sweep: nothing above, so the down call here is next anyway.
served.add(new HallCall(floor, Direction.DOWN));
direction = Direction.DOWN;
} else if (direction == Direction.DOWN && lowestStop() >= floor && upCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.UP));
direction = Direction.UP;
}
return served;
}
// LOOK: keep going while there is work ahead, then turn around. Every stop in the car's
// way is served on this sweep, and everything else on the next, so no call waits forever.
private Direction nextDirection() {
boolean above = highestStop() > floor, below = lowestStop() < floor;
if (direction == Direction.UP && above) return Direction.UP;
if (direction == Direction.DOWN && below) return Direction.DOWN;
if (above) return Direction.UP;
if (below) return Direction.DOWN;
return Direction.IDLE;
}
// The farthest stops in each direction. With no stops, both are the current floor.
int highestStop() { return max(max(max(floor, carStops), upCalls), downCalls); }
int lowestStop() { return min(min(min(floor, carStops), upCalls), downCalls); }
private static int max(int f, TreeSet<Integer> s) { return s.isEmpty() ? f : Math.max(f, s.last()); }
private static int min(int f, TreeSet<Integer> s) { return s.isEmpty() ? f : Math.min(f, s.first()); }
private void check(int f) {
if (f < 0 || f >= floors) throw new IllegalArgumentException("no floor " + f);
}
int floor() { return floor; }
Direction direction() { return direction; }
boolean doorsOpen() { return doorsOpen; }
}
// The building's entry point. Buttons call it; it owns the cars and asks the dispatcher which car
// answers each hall call. It also remembers every call it accepted and which car holds it: that
// map makes a second press harmless and lets a broken car's calls move.
class ElevatorSystem {
private final List<Car> cars = new ArrayList<>();
private final Dispatcher dispatcher;
private final Map<HallCall, Car> assigned = new HashMap<>(); // lit buttons -> who answers them
private final Deque<HallCall> waiting = new ArrayDeque<>(); // accepted, but no car can take them yet
ElevatorSystem(int floors, int carCount, Dispatcher dispatcher) {
for (int i = 0; i < carCount; i++) cars.add(new Car(i, floors));
this.dispatcher = dispatcher;
}
void hallCall(int floor, Direction d) {
HallCall call = new HallCall(floor, d);
// People press a lit button again and again. Only the first press is a new call.
if (assigned.containsKey(call) || waiting.contains(call)) return;
if (!assign(call)) waiting.addLast(call);
}
void carCall(int carId, int floor) { cars.get(carId).carCall(floor); }
// The car's calls go to other cars, or wait if none can take them.
void takeOutOfService(int carId) {
for (HallCall orphan : cars.get(carId).takeOutOfService()) {
assigned.remove(orphan);
if (!assign(orphan)) waiting.addLast(orphan);
}
}
void returnToService(int carId) { cars.get(carId).returnToService(); }
void setFull(int carId, boolean full) { cars.get(carId).setFull(full); }
void step() {
// Calls that found no car get another chance every tick, oldest first.
for (int i = waiting.size(); i > 0; i--) {
HallCall c = waiting.removeFirst();
if (!assign(c)) waiting.addLast(c);
}
for (Car car : cars) for (HallCall served : car.step()) assigned.remove(served);
}
// Dispatchers only ever see cars that can take the call, so no dispatcher has to
// remember to skip broken or full cars.
private boolean assign(HallCall call) {
List<Car> candidates = cars.stream().filter(Car::accepting).toList();
if (candidates.isEmpty()) return false;
Car car = dispatcher.choose(candidates, call);
car.hallCall(call);
assigned.put(call, car);
return true;
}
boolean isLit(HallCall call) { return assigned.containsKey(call) || waiting.contains(call); }
Car car(int id) { return cars.get(id); }
}

“Out of service” and “full” are two simple flags on the car, plus one early return in step(). The sweep logic stays in one place.

Go deeperFlags now, the State pattern later1 min

Real buildings have more modes: fire service (send every car to the lobby, ignore floor calls), a porter driving the car, VIP. Each one changes several behaviors at once. With three or more of those, a separate object per mode (the State pattern) beats a growing pile of flags.

Go deeperWhat happens to riders inside a car taken out of service?1 min

In our model, the car stops at the floor it’s on and opens its doors. Its car stops are cleared, because those riders step out. A real car would first finish moving to the nearest floor. If riders must be taken to their floors first, “out of service” becomes “stop accepting floor calls, finish the car stops, then park”. That’s one more mode, and a good reason to switch to the State pattern.

This is enough for a mid-level answer. A car sweeps with floor calls that carry a direction, and a dispatcher chooses the car with a cost that counts direction. The rest of the page is what makes it a senior answer.

Here is everything together. Each class is here because a requirement needed it. Controller comes from the next section.

Elevator system: full class diagram

1

1

inbox

*

publishes

1

*

*

Controller

-inbox: Queue<Event>

-snapshot: List<CarStatus>

+post(Event)

+tick()

ElevatorSystem

-cars: List<Car>

-assigned: Map<HallCall,Car>

-waiting: Queue<HallCall>

+hallCall(floor, Direction)

+carCall(carId, floor)

+takeOutOfService(carId)

+setFull(carId, bool)

+step()

Car

+id

-floor

-direction: Direction

-carStops: SortedSet

-upCalls: SortedSet

-downCalls: SortedSet

+step() : List<HallCall>

+accepting() : bool

+takeOutOfService() : List<HallCall>

«interface»

Dispatcher

+choose(cars, HallCall) : Car

NearestCarDispatcher

«interface»

Event

«value»

HallPressed

«value»

CarPressed

«value»

LoadChanged

«value»

ServiceChanged

«value»

HallCall

+floor

+direction

«value»

CarStatus

+id

+floor

+direction

+doorsOpen

«enum»

Direction

UP

DOWN

IDLE

1

1

inbox

*

publishes

1

*

*

Controller

-inbox: Queue<Event>

-snapshot: List<CarStatus>

+post(Event)

+tick()

ElevatorSystem

-cars: List<Car>

-assigned: Map<HallCall,Car>

-waiting: Queue<HallCall>

+hallCall(floor, Direction)

+carCall(carId, floor)

+takeOutOfService(carId)

+setFull(carId, bool)

+step()

Car

+id

-floor

-direction: Direction

-carStops: SortedSet

-upCalls: SortedSet

-downCalls: SortedSet

+step() : List<HallCall>

+accepting() : bool

+takeOutOfService() : List<HallCall>

«interface»

Dispatcher

+choose(cars, HallCall) : Car

NearestCarDispatcher

«interface»

Event

«value»

HallPressed

«value»

CarPressed

«value»

LoadChanged

«value»

ServiceChanged

«value»

HallCall

+floor

+direction

«value»

CarStatus

+id

+floor

+direction

+doorsOpen

«enum»

Direction

UP

DOWN

IDLE

Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Entity
  • Value object
  • Enum
  • Interface
  • Implementation
elevator· read only
AllInOne.java
AllInOne.java
// The complete design, built from every stage on this page.
import java.util.*;
import java.util.concurrent.*;
// One elevator car. It keeps its own stops and moves one tick at a time, sweeping up and down
// (the LOOK rule below). A full car or one out of service gets no new hall calls.
class Car {
final int id;
private final int floors; // floors are 0 .. floors - 1
private int floor;
private Direction direction = Direction.IDLE;
private boolean doorsOpen;
// Three kinds of stop, each a sorted set so "anything above me?" is one lookup.
// A car stop is served whichever way the car is going. An up call only by a car going up.
private final TreeSet<Integer> carStops = new TreeSet<>();
private final TreeSet<Integer> upCalls = new TreeSet<>();
private final TreeSet<Integer> downCalls = new TreeSet<>();
private boolean inService = true;
private boolean full; // from the load sensor
Car(int id, int floors) { this.id = id; this.floors = floors; }
// A full car still takes its riders where they asked, but shouldn't be sent for more.
boolean accepting() { return inService && !full; }
void setFull(boolean full) { this.full = full; }
// Stop here with the doors open, and hand back the hall calls this car was holding so
// the building can give them to another car. Without this, those people wait forever.
List<HallCall> takeOutOfService() {
inService = false;
doorsOpen = true;
direction = Direction.IDLE;
List<HallCall> orphans = new ArrayList<>();
for (int f : upCalls) orphans.add(new HallCall(f, Direction.UP));
for (int f : downCalls) orphans.add(new HallCall(f, Direction.DOWN));
upCalls.clear();
downCalls.clear();
carStops.clear(); // riders step out here; their buttons no longer mean anything
return orphans;
}
void returnToService() { inService = true; } // the doors close on the next tick
// A button inside the car: stop at f whichever way the car is going.
void carCall(int f) { check(f); carStops.add(f); }
// A hall call the building gave this car: stop at that floor going that way.
void hallCall(HallCall c) {
check(c.floor());
(c.direction() == Direction.UP ? upCalls : downCalls).add(c.floor());
}
// One tick: close the doors, or open them for the stops here, or move one floor.
// Returns the hall calls answered this tick, so the building can turn their lights off.
List<HallCall> step() {
if (!inService) return List.of();
if (doorsOpen) { doorsOpen = false; return List.of(); }
boolean carStop = carStops.remove(floor); // before serveHere, which checks what is left above
List<HallCall> served = serveHere();
if (carStop || !served.isEmpty()) { doorsOpen = true; return served; }
direction = nextDirection();
if (direction == Direction.UP) floor++;
else if (direction == Direction.DOWN) floor--;
return List.of();
}
// The hall calls this floor can answer, given the way the car is heading.
private List<HallCall> serveHere() {
List<HallCall> served = new ArrayList<>();
if (direction != Direction.DOWN && upCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.UP));
direction = Direction.UP;
} else if (direction != Direction.UP && downCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.DOWN));
direction = Direction.DOWN;
} else if (direction == Direction.UP && highestStop() <= floor && downCalls.remove(floor)) {
// The top of this sweep: nothing above, so the down call here is next anyway.
served.add(new HallCall(floor, Direction.DOWN));
direction = Direction.DOWN;
} else if (direction == Direction.DOWN && lowestStop() >= floor && upCalls.remove(floor)) {
served.add(new HallCall(floor, Direction.UP));
direction = Direction.UP;
}
return served;
}
// LOOK: keep going while there is work ahead, then turn around. Every stop in the car's
// way is served on this sweep, and everything else on the next, so no call waits forever.
private Direction nextDirection() {
boolean above = highestStop() > floor, below = lowestStop() < floor;
if (direction == Direction.UP && above) return Direction.UP;
if (direction == Direction.DOWN && below) return Direction.DOWN;
if (above) return Direction.UP;
if (below) return Direction.DOWN;
return Direction.IDLE;
}
// The farthest stops in each direction. With no stops, both are the current floor.
int highestStop() { return max(max(max(floor, carStops), upCalls), downCalls); }
int lowestStop() { return min(min(min(floor, carStops), upCalls), downCalls); }
private static int max(int f, TreeSet<Integer> s) { return s.isEmpty() ? f : Math.max(f, s.last()); }
private static int min(int f, TreeSet<Integer> s) { return s.isEmpty() ? f : Math.min(f, s.first()); }
private void check(int f) {
if (f < 0 || f >= floors) throw new IllegalArgumentException("no floor " + f);
}
int floor() { return floor; }
Direction direction() { return direction; }
boolean doorsOpen() { return doorsOpen; }
}
// Which way a car is heading. IDLE means it has nothing left to do.
enum Direction { UP, DOWN, IDLE }
// A button pressed on a floor. It is a promise to arrive *going that way*, which is
// why it carries a direction and a button inside the car does not.
record HallCall(int floor, Direction direction) {}
// Picks which car answers a hall call. The rule changes from building to building
// (and from morning to evening), so it is a strategy the system is given.
interface Dispatcher {
Car choose(List<Car> cars, HallCall call);
}
// Sends the car that would arrive soonest, counted in floors travelled. A car already
// heading toward the caller in the caller's direction is cheap; one that must finish its
// sweep and come back pays for the whole detour.
class NearestCarDispatcher implements Dispatcher {
public Car choose(List<Car> cars, HallCall call) {
// Ties go to the lower id, so the same situation always gets the same answer.
return cars.stream()
.min(Comparator.comparingInt((Car c) -> cost(c, call)).thenComparingInt(c -> c.id))
.orElseThrow();
}
static int cost(Car c, HallCall call) {
int at = c.floor(), f = call.floor();
return switch (c.direction()) {
case IDLE -> Math.abs(at - f);
case UP -> {
if (call.direction() == Direction.UP && f >= at) yield f - at; // on the way
int turn = Math.max(c.highestStop(), f); // up to the top, then back
yield (turn - at) + (turn - f);
}
case DOWN -> {
if (call.direction() == Direction.DOWN && f <= at) yield at - f;
int turn = Math.min(c.lowestStop(), f);
yield (at - turn) + (f - turn);
}
};
}
}
// The building's entry point. Buttons call it; it owns the cars and asks the dispatcher which car
// answers each hall call. It also remembers every call it accepted and which car holds it: that
// map makes a second press harmless and lets a broken car's calls move.
class ElevatorSystem {
private final List<Car> cars = new ArrayList<>();
private final Dispatcher dispatcher;
private final Map<HallCall, Car> assigned = new HashMap<>(); // lit buttons -> who answers them
private final Deque<HallCall> waiting = new ArrayDeque<>(); // accepted, but no car can take them yet
ElevatorSystem(int floors, int carCount, Dispatcher dispatcher) {
for (int i = 0; i < carCount; i++) cars.add(new Car(i, floors));
this.dispatcher = dispatcher;
}
void hallCall(int floor, Direction d) {
HallCall call = new HallCall(floor, d);
// People press a lit button again and again. Only the first press is a new call.
if (assigned.containsKey(call) || waiting.contains(call)) return;
if (!assign(call)) waiting.addLast(call);
}
void carCall(int carId, int floor) { cars.get(carId).carCall(floor); }
// The car's calls go to other cars, or wait if none can take them.
void takeOutOfService(int carId) {
for (HallCall orphan : cars.get(carId).takeOutOfService()) {
assigned.remove(orphan);
if (!assign(orphan)) waiting.addLast(orphan);
}
}
void returnToService(int carId) { cars.get(carId).returnToService(); }
void setFull(int carId, boolean full) { cars.get(carId).setFull(full); }
void step() {
// Calls that found no car get another chance every tick, oldest first.
for (int i = waiting.size(); i > 0; i--) {
HallCall c = waiting.removeFirst();
if (!assign(c)) waiting.addLast(c);
}
for (Car car : cars) for (HallCall served : car.step()) assigned.remove(served);
}
// Dispatchers only ever see cars that can take the call, so no dispatcher has to
// remember to skip broken or full cars.
private boolean assign(HallCall call) {
List<Car> candidates = cars.stream().filter(Car::accepting).toList();
if (candidates.isEmpty()) return false;
Car car = dispatcher.choose(candidates, call);
car.hallCall(call);
assigned.put(call, car);
return true;
}
boolean isLit(HallCall call) { return assigned.containsKey(call) || waiting.contains(call); }
Car car(int id) { return cars.get(id); }
}
// Everything that can happen to the building from outside, as values. Buttons, load sensors
// and the maintenance panel run on their own threads; all they ever do is create one of these.
sealed interface Event permits HallPressed, CarPressed, LoadChanged, ServiceChanged {}
record HallPressed(int floor, Direction direction) implements Event {}
record CarPressed(int carId, int floor) implements Event {}
record LoadChanged(int carId, boolean full) implements Event {}
record ServiceChanged(int carId, boolean inService) implements Event {}
// What a lobby display shows. Immutable, so any thread can read it without a lock.
record CarStatus(int id, int floor, Direction direction, boolean doorsOpen) {}
// The one thread that owns the building. Other threads post events; only tick() touches
// ElevatorSystem, so the cars, the dispatcher and the call map never need a lock.
class Controller {
private final ElevatorSystem system;
private final int carCount;
private final ConcurrentLinkedQueue<Event> inbox = new ConcurrentLinkedQueue<>();
// Replaced whole after every tick. volatile makes the new list visible to other threads.
private volatile List<CarStatus> snapshot = List.of();
Controller(ElevatorSystem system, int carCount) { this.system = system; this.carCount = carCount; }
// Safe from any thread, and never blocks a button waiting for the loop.
void post(Event e) { inbox.add(e); }
// Called by the control loop every tick, or directly by tests. Events are applied between
// ticks, never in the middle of one, so the dispatcher always sees every car at rest.
void tick() {
for (Event e; (e = inbox.poll()) != null; ) {
try {
apply(e);
} catch (IllegalArgumentException bad) {
// A press for a floor that doesn't exist is dropped. Letting it escape would end
// scheduleAtFixedRate's loop for good: one bad button would stop every car.
}
}
system.step();
List<CarStatus> s = new ArrayList<>();
for (int i = 0; i < carCount; i++) {
Car c = system.car(i);
s.add(new CarStatus(i, c.floor(), c.direction(), c.doorsOpen()));
}
snapshot = List.copyOf(s);
}
// Turns one event into one call on the building.
private void apply(Event e) {
switch (e) {
case HallPressed h -> system.hallCall(h.floor(), h.direction());
case CarPressed c -> system.carCall(c.carId(), c.floor());
case LoadChanged l -> system.setFull(l.carId(), l.full());
case ServiceChanged s -> {
if (s.inService()) system.returnToService(s.carId());
else system.takeOutOfService(s.carId());
}
}
}
List<CarStatus> snapshot() { return snapshot; }
// One thread at a fixed rate. The executor never runs two ticks at once, which is the
// whole concurrency design: there is exactly one writer.
ScheduledFuture<?> start(ScheduledExecutorService exec, long periodMs) {
return exec.scheduleAtFixedRate(this::tick, 0, periodMs, TimeUnit.MILLISECONDS);
}
}
Read onlyJava 21Every file builds in CI14 files287 lines
all_in_one.py
all_in_one.py
Read onlyPython 3.12Every file builds in CI13 files323 lines
AllInOne.cs
AllInOne.cs
Read onlyC# · .NET 10Every file builds in CI14 files296 lines
all_in_one.go
all_in_one.go
Read onlyGo 1.24Every file builds in CI14 files429 lines
all-in-one.ts
all-in-one.ts
Read onlyTypeScript, strictEvery file builds in CI10 files314 lines

Three ideas hold it together:

  • Two decisions, two owners. The order of stops belongs to each Car: LOOK, fixed, and the reason nobody waits forever. The choice of car belongs to the Dispatcher (Strategy): swappable, and the place to improve speed. Changing one never touches the other.
  • The system remembers every promise. A lit button is an entry in the call map or the waiting queue. Repeated presses, breakdowns and full cars are all handled by looking at that one record.
  • Time and threads have one owner. Nothing moves except on a tick, and only the controller’s loop calls it. So no class below it needs a lock. Button presses become small event objects that wait in a queue (the Command pattern), and to the buttons, the system is a simple front door (a facade).

The design works when one caller presses buttons and ticks the clock. But in a real building, buttons on thirty floors and inside four cars each report on their own thread.

Interviewerminute 28

Buttons on thirty floors and inside four cars are all pressed at once, each on its own thread. Is your system safe?

What they are testing

Whether you pick one way of owning the state, and argue for it. Locks on every car plus a lock on the system leads to “now the dispatcher reads four cars: in what lock order?”. One owner thread with a queue is the strong answer, if you can say what it costs.

The danger isn’t one car’s fields getting mixed up. It’s decisions made on numbers that were never true together. The dispatcher reads the floor, direction and farthest stops of every car to choose one. If a car moves halfway through that read, the dispatcher compares a car’s old position with another car’s new one. And if a second press of the same button runs at the same moment, both presses find the button unlit, and the call is assigned twice.

The clearly correct fix is one lock around the whole system. Every press and every tick takes the same lock. That’s safe. But presses wait behind ticks, and every new method has to remember to take the lock.

The better fix is one owner. Buttons, load sensors and the maintenance panel never touch the system directly. They put an event into a thread-safe queue, called the inbox, and return at once. One control loop, on one thread, empties the inbox and then ticks. Because only that thread ever touches ElevatorSystem, the cars, the call map and the dispatcher are all single-threaded code. And every dispatcher decision sees every car standing still.

Option How Correct? Cost
1. One lock around ElevatorSystem Every press and every tick takes it Yes Presses wait behind ticks; every new method must remember it
2. A lock per car, plus one for the map The dispatcher locks all cars to read them Yes, with a fixed lock order Threads can block each other forever if any path takes locks in another order
3. One owner loop with an inbox (our choice) Presses become events in a queue; one thread applies them and ticks Yes, by design A press waits up to one tick to take effect
Sequence: presses from many threads, one owner
ElevatorSystemControl loop threadInbox (concurrent queue)Car 1 panel threadFloor 3 button threadElevatorSystemControl loop threadInbox (concurrent queue)Car 1 panel threadFloor 3 button threadboth return at oncepost(HallPressed 3 UP)post(CarPressed car 1, floor 9)drain[HallPressed 3 UP, CarPressed 1 9]hallCall(3, UP)carCall(1, 9)step()publish snapshotElevatorSystemControl loop threadInbox (concurrent queue)Car 1 panel threadFloor 3 button threadElevatorSystemControl loop threadInbox (concurrent queue)Car 1 panel threadFloor 3 button threadboth return at oncepost(HallPressed 3 UP)post(CarPressed car 1, floor 9)drain[HallPressed 3 UP, CarPressed 1 9]hallCall(3, UP)carCall(1, 9)step()publish snapshot
Scroll or pinch to zoom, drag to pan
  • Color key
  • Facade
  • Queue or stream
  • Entity
Controller.javacontroller.pyController.cscontroller.gocontroller.ts
// Everything that can happen to the building from outside, as values. Buttons, load sensors
// and the maintenance panel run on their own threads; all they ever do is create one of these.
sealed interface Event permits HallPressed, CarPressed, LoadChanged, ServiceChanged {}
record HallPressed(int floor, Direction direction) implements Event {}
record CarPressed(int carId, int floor) implements Event {}
record LoadChanged(int carId, boolean full) implements Event {}
record ServiceChanged(int carId, boolean inService) implements Event {}
// What a lobby display shows. Immutable, so any thread can read it without a lock.
record CarStatus(int id, int floor, Direction direction, boolean doorsOpen) {}
// The one thread that owns the building. Other threads post events; only tick() touches
// ElevatorSystem, so the cars, the dispatcher and the call map never need a lock.
class Controller {
private final ElevatorSystem system;
private final int carCount;
private final ConcurrentLinkedQueue<Event> inbox = new ConcurrentLinkedQueue<>();
// Replaced whole after every tick. volatile makes the new list visible to other threads.
private volatile List<CarStatus> snapshot = List.of();
Controller(ElevatorSystem system, int carCount) { this.system = system; this.carCount = carCount; }
// Safe from any thread, and never blocks a button waiting for the loop.
void post(Event e) { inbox.add(e); }
// Called by the control loop every tick, or directly by tests. Events are applied between
// ticks, never in the middle of one, so the dispatcher always sees every car at rest.
void tick() {
for (Event e; (e = inbox.poll()) != null; ) {
try {
apply(e);
} catch (IllegalArgumentException bad) {
// A press for a floor that doesn't exist is dropped. Letting it escape would end
// scheduleAtFixedRate's loop for good: one bad button would stop every car.
}
}
system.step();
List<CarStatus> s = new ArrayList<>();
for (int i = 0; i < carCount; i++) {
Car c = system.car(i);
s.add(new CarStatus(i, c.floor(), c.direction(), c.doorsOpen()));
}
snapshot = List.copyOf(s);
}
// Turns one event into one call on the building.
private void apply(Event e) {
switch (e) {
case HallPressed h -> system.hallCall(h.floor(), h.direction());
case CarPressed c -> system.carCall(c.carId(), c.floor());
case LoadChanged l -> system.setFull(l.carId(), l.full());
case ServiceChanged s -> {
if (s.inService()) system.returnToService(s.carId());
else system.takeOutOfService(s.carId());
}
}
}
List<CarStatus> snapshot() { return snapshot; }
// One thread at a fixed rate. The executor never runs two ticks at once, which is the
// whole concurrency design: there is exactly one writer.
ScheduledFuture<?> start(ScheduledExecutorService exec, long periodMs) {
return exec.scheduleAtFixedRate(this::tick, 0, periodMs, TimeUnit.MILLISECONDS);
}
}

Why is this correct? Exactly one thread reads or writes the system, so nothing below the controller can race. The inbox is the only shared object, and it’s a library queue built for many writers and one reader. Screens read a small status snapshot that’s built after each tick and replaced whole. A reader gets the old snapshot or the new one, never a mix.

What does it cost? A press takes effect on the next tick, not instantly, and all the work runs on one thread. For an elevator bank, that’s nothing: a few cars, and a tick every few hundred milliseconds. No rider notices half a second. A system with thousands of independent units would split them across several loops.

Go deeperTwo details that matter in production1 min

A bad event must not stop the loop. A press for a floor that doesn’t exist throws an error inside the loop. In Java, an error escaping a scheduleAtFixedRate task cancels every future run. In Node, an uncaught error in a timer crashes the process. Either way, one faulty button would stop every car. So the controller drops invalid events and keeps going.

The tick must be short. If a tick waited on a disk or the network, every press would wait behind it. Go uses a channel as the inbox and a ticker, which is the same design in Go’s own style: “share memory by communicating”.

Go deeperWhat if the controller crashes and restarts?1 min

The state that matters is small: each car’s position (read back from the hardware’s floor sensors) and the lit buttons. A restarted controller rebuilds the cars from the sensor readings, and treats every lit button as a fresh floor call. Riders inside press their floor again if the car stops. Saving the inbox would allow an exact replay, but rebuilding from sensors is simpler, and it’s what the hardware already supports.

Because time is a method, every test is a script of presses and ticks. The ones that matter:

  • A car visits stops in sweep order, not press order.
  • A car going up passes a down call, and takes it on the way back.
  • A down call at the top of a sweep turns the car around.
  • The dispatcher skips a near car that’s heading away.
  • No call waits forever under heavy traffic.
  • A broken car’s calls move to another car, and the button goes dark when answered.
  • Pressing a lit button again isn’t a new call.
  • Events sent from many threads are all applied, and a bad one doesn’t stop the tick.

The starvation test protects the main promise. Riders keep asking for floors 1 and 2 on every tick, while one person waits on floor 19. A LOOK car reaches 19 within a couple of sweeps. If someone “improves” the car to nearest-first, the test fails, because the car never leaves the bottom of the building.

Go deeperThe tests in five languages2 min

The many-threads test sends a hundred presses from eight threads, and checks that every button is lit after a single tick.

Main.javaexample.pyCountingDispatcher.cscounting_dispatcher.goexample.ts
// Steps a car until it is idle, and returns the floors where its doors opened, in order.
static List<Integer> stops(Car car, int maxTicks) {
List<Integer> opened = new ArrayList<>();
for (int t = 0; t < maxTicks; t++) {
car.step();
if (car.doorsOpen()) opened.add(car.floor());
}
return opened;
}
@Test
void aCarVisitsStopsInSweepOrderNotPressOrder() {
Car car = new Car(0, 10);
car.carCall(5); car.carCall(2); car.carCall(8);
assertEquals(List.of(2, 5, 8), stops(car, 30)); // first come, first served would be 5, 2, 8
}
@Test
void aCarGoingUpPassesADownCallAndTakesItOnTheWayBack() {
Car car = new Car(0, 10);
car.carCall(9);
car.hallCall(new HallCall(5, Direction.DOWN));
assertEquals(List.of(9, 5), stops(car, 30)); // the person on 5 wants down, so not on the way up
}
@Test
void aDownCallAtTheTopTurnsTheCarAround() {
Car car = new Car(0, 10);
car.hallCall(new HallCall(8, Direction.DOWN));
stops(car, 9); // 8 floors up, then the doors open
assertTrue(car.doorsOpen());
assertEquals(Direction.DOWN, car.direction()); // the lantern shows the way they are going
}
@Test
void theDispatcherSkipsANearCarThatIsHeadingAway() {
ElevatorSystem s = new ElevatorSystem(20, 2, new NearestCarDispatcher());
s.carCall(0, 19); // car 0 heads for the top
s.carCall(1, 6); // car 1 stops at 6 and goes idle
for (int t = 0; t < 12; t++) s.step(); // car 0 on 12 going up, car 1 idle on 6
Car pick = new NearestCarDispatcher().choose(List.of(s.car(0), s.car(1)), new HallCall(11, Direction.UP));
assertEquals(1, pick.id); // car 0 is one floor away, but 15 floors from arriving
}
@Test
void noCallWaitsForeverUnderHeavyTraffic() {
// Riders keep asking for 1 and 2. A nearest-stop-first car would never leave the bottom.
Car car = new Car(0, 20);
car.hallCall(new HallCall(19, Direction.DOWN));
for (int t = 0; t < 60; t++) {
int f = t % 2 + 1;
if (car.floor() != f) car.carCall(f);
car.step();
if (car.floor() == 19 && car.doorsOpen()) return;
}
throw new AssertionError("the call on 19 was never answered");
}
@Test
void aBrokenCarsCallsMoveToAnotherCar() {
ElevatorSystem s = new ElevatorSystem(10, 2, new NearestCarDispatcher());
s.hallCall(7, Direction.DOWN); // both idle on 0: the tie goes to car 0
s.takeOutOfService(0);
for (int t = 0; t < 10 && !(s.car(1).floor() == 7 && s.car(1).doorsOpen()); t++) s.step();
assertEquals(7, s.car(1).floor());
assertFalse(s.isLit(new HallCall(7, Direction.DOWN)));
}
@Test
void pressingALitButtonAgainIsNotANewCall() {
int[] chosen = {0};
Dispatcher counting = (cars, call) -> { chosen[0]++; return cars.get(0); };
ElevatorSystem s = new ElevatorSystem(10, 2, counting);
for (int i = 0; i < 5; i++) s.hallCall(4, Direction.UP);
assertEquals(1, chosen[0]);
}
@Test
void eventsPostedFromManyThreadsAreAllApplied() throws Exception {
ElevatorSystem system = new ElevatorSystem(50, 3, new NearestCarDispatcher());
Controller c = new Controller(system, 3);
ExecutorService buttons = Executors.newFixedThreadPool(8);
for (int f = 0; f < 50; f++) {
int floor = f;
buttons.submit(() -> c.post(new HallPressed(floor, Direction.UP)));
buttons.submit(() -> c.post(new HallPressed(floor, Direction.DOWN)));
}
buttons.shutdown();
buttons.awaitTermination(5, TimeUnit.SECONDS);
c.post(new CarPressed(0, 99)); // a floor that doesn't exist: dropped, not fatal
c.tick();
assertEquals(3, c.snapshot().size()); // the tick finished despite the bad press
// Every press landed. The ones on floor 0 were answered in the same tick by the cars there.
for (int f = 1; f < 50; f++) {
assertTrue(system.isLit(new HallCall(f, Direction.UP)));
assertTrue(system.isLit(new HallCall(f, Direction.DOWN)));
}
}
Go deeperEvery edge case, and where it is handled2 min
Case What happens Where
Rider presses the floor the car is on Doors open on the next tick Car.step
Down call at the top of a sweep Taken at the turnaround; the car’s direction becomes down Car.serveHere
Up call below a car going up Served on the next up sweep LOOK in Car.nextDirection
Lit button pressed again No new call ElevatorSystem.hallCall
Car taken out of service holding calls Calls handed back and reassigned takeOutOfService
Every car full or out of service The call waits and is offered again every tick ElevatorSystem.step
Full car with calls already assigned Keeps them; riders may get off before it arrives Car.accepting filters new ones only
Car returns to service Doors close next tick; it can be chosen again returnToService
Press for a floor that doesn’t exist Dropped; the loop keeps running Controller.tick
Two presses of one button in one tick The second finds the call lit the call map
Screen reads during a tick Sees the previous snapshot, whole unchanging CarStatus list
Decision We chose We gave up
Time An explicit step() tick, called by the owner Modeling motors and doors in real time
Stop order in a car LOOK, with car stops, up calls and down calls The shortest possible travel at every moment
Floor calls A value with a floor and a direction Nothing; a floor alone is wrong
Which car answers Strategy (Dispatcher), cost counted to arrival An estimate that ignores stops along the way
When the choice is made Once, when pressed Better choices from reassigning every tick
Broken and full cars A record of every accepted call; broken cars hand theirs back One more map to keep in step
Special modes Flags for now State objects once modes multiply
Concurrency One owner loop with an inbox of events Up to one tick of delay per press
Go deeperGenuinely different designs, and when to pick them2 min

A thread per car, in real time. Each car runs its own loop, sleeping between floors, with a locked list of stops. It mirrors the physical building, and many tutorials show it. It’s harder to test, needs locking between the dispatcher and the cars, and gains nothing, because cars don’t need to run in parallel. Pick it only if each car’s controller runs on separate hardware. Then it’s a distributed system with messages, not threads.

Choosing the best plan on every tick. Instead of choosing a car once per call, work out the best assignment of all waiting calls to all cars on every tick, for example to make the longest waits as short as possible. This is roughly what modern controllers do, with clever shortcuts. It gives better averages, but calls can move between cars, so the lantern over the doors can’t be lit early. It fits in as a dispatcher that runs from ElevatorSystem.step.

A simulation that jumps between events. Instead of fixed ticks, jump the clock straight to the next interesting moment (a car arriving, doors closing). It’s far faster for simulating a whole day to compare dispatchers. For the controller itself, fixed ticks are simpler and match how the hardware reports.

  • 0 to 3. Floors and cars, two buttons or a keypad, and what “good” means: nobody waits forever first.
  • 3 to 5. The promises. Name “no call waits forever” as the hard one.
  • 5 to 25. One car with ticks, then floor calls with direction and LOOK, then the dispatcher, then broken and full cars with the call map. Code Car.step and the dispatcher’s cost, and describe the call map.
  • 25 to 28. Walk through the full diagram: two decisions with two owners, one record of promises.
  • 28 to 36. Concurrency: the inbox and the single owner loop, and why not locks per car.
  • 36 to 45. Destination dispatch or rush-hour modes, then your own questions.

If time is short, describe stage 4 in words. Never skip the starvation argument for LOOK. It’s the reason the design is what it is.

Interviewerminute 36

The building wants destination dispatch: riders type their floor in the lobby, and the screen tells them which car to take. What changes?

What they are testing

Whether you see that the request now carries both ends of the trip, so the dispatcher can group riders going to nearby floors, and that the car’s sweep doesn’t change at all.

Most extensions fit into one place:

Extension What changes Touches Car?
Destination dispatch A TripRequest(from, to); a dispatcher that groups trips; the car gets a floor call and a car stop No
Morning rush A second dispatcher that parks idle cars at the lobby, switched by time of day No
Zoned or express cars The system filters cars by which floors they serve No
Fire service A mode that sends every car to the lobby and ignores floor calls Yes: mode as State
Reassign stale calls The system re-runs the dispatcher for waiting calls every few ticks No
Energy saving A dispatcher whose cost adds a penalty for starting an idle car No
Go deeperAdding destination dispatch, step by step2 min

With up and down buttons, the system learns where a rider is going only after they step into the car. With a keypad in the lobby, it knows both ends of the trip before choosing a car. That’s the whole change, and it lands in the request and the dispatcher.

  1. A new request. TripRequest(from, to) replaces “up on floor 1”. The direction isn’t needed any more: it follows from the two floors.
  2. A new dispatcher. It groups riders going to nearby floors into the same car. Five people going to floors 20 to 23 share one car, which then makes four stops instead of each car making one or two. The cost function adds “how many new stops does this trip add to this car?” to the arrival time we already had.
  3. The answer goes back to the rider. The keypad shows “take car B”. So the choice must stick, which we already do: a car is chosen once, when the request arrives.
  4. The car gets two ordinary stops. The system turns one trip into a floor call at from (in the direction of to) and a car stop at to. The car doesn’t know destination dispatch exists. Its sweep, and the promise that nobody waits forever, are unchanged.

One new edge case appears: a rider who types a floor and then gets into the wrong car. The car stop at to is already set on the right car, so the wrong car doesn’t know where they want to go. Most buildings answer this with a keypad inside each car too, so a rider can still press their floor.

Go deeperFour more follow-up questions2 min
Interviewer asksHow would you measure whether a new dispatcher is better?

Simulate. Because time is a tick and every input is an event, a script can replay a recorded day of button presses against two dispatchers, and compare the average wait, the 95th percentile wait, and the longest wait. The longest wait matters most: a dispatcher that improves the average by leaving one floor waiting is worse.

Interviewer asksTwo floor calls on the same floor, one up and one down. Same car?

Not necessarily. They’re different calls with different directions, and the dispatcher picks the best car for each. Often the same car takes both, one on each sweep. The call map uses floor and direction together as its key, so they never clash.

Interviewer asksWhere does the 'door open' button inside the car fit?

It’s one more event. The controller applies it by telling the car to keep its doors open for another tick (a holdDoors counter that step counts down before closing). It never moves the car, so it doesn’t affect the sweep or the dispatcher.

Interviewer asksHow would you scale this to a campus of fifty buildings?

Each building is independent, so each gets its own controller and loop, on its own hardware in practice. A campus dashboard reads each building’s published snapshot. Nothing about one building’s dispatching depends on another, so there’s no shared state to coordinate.