Design an Elevator System
Design an Elevator System
You already know the ingredients in isolation: a state machine from the State Pattern page, and a pluggable algorithm from the Strategy Pattern walkthrough. "Design an Elevator System" is the interview question that forces you to wire those two ideas into one working system: a car that is IDLE/UP/DOWN, a scheduler that decides which floor it visits next, and — the moment the interviewer asks "what about 10 cars in one building" — a dispatcher that decides which car answers which call. This project makes you build that in Go and Java, from an empty file, and prove why naive first-come-first-served scheduling is the wrong answer before you even get to the concurrency bug hiding underneath it.
The Trap
Model an elevator the most literal way possible: a queue of requested floors, serviced in the order they arrived.
// The naive approach — looks reasonable, ships broken.
Queue<Integer> requests = new LinkedList<>();
while (true) {
int destination = requests.poll(); // whichever floor was requested first
moveTo(destination); // travel there directly, however far
}
Ride it as a passenger. The car sits at floor 0. Floor 5 requests it, then floor 1, then floor 9, then floor 2, then floor 8. FCFS honors that literal arrival order: 0 → 5 → 1 → 9 → 2 → 8. You just watched the car climb to 5, drop nearly to the ground, rocket to the top, crash back down near the bottom, then climb again — for five requests that a sane building services in one pass from bottom to top. Worse than the wasted travel: if a sixth request keeps re-arriving near the bottom every time the car passes by, the car keeps servicing that neighborhood while a request sitting at floor 9 waits arbitrarily far back in the queue. That's starvation — a request's wait time depends on queue position, not on how close the car already is to it. The naive model isn't just slow, it's incoherent: it ignores the one fact that should drive every decision — where the car physically is right now, and which way it's already moving.
Scope it like a senior
Pin down the contract before writing a scheduler — these are the questions that separate "I can draw the classes" from "I understand what a real elevator bank promises":
- One car or N? A single car is Milestones 1–3; the moment there's more than one car, something has to decide which car answers a hall call — that's a Dispatcher, and it's where the interview usually goes next (M4).
- Hall call vs. car call? A hall call is external: a rider on floor 7 presses ▲ or ▼ — the request carries a floor and a direction, but no destination yet (nobody knows which floor they actually want until they board). A car call is internal: a rider already inside presses "12" — that request carries only a destination floor, no direction (the car is already committed to serving it). This distinction changes how a request gets registered (Reason to the design, below) and is a favorite "did you actually think about this" follow-up.
- Capacity? A car has a maximum occupancy; assigning a hall call to a full car is a wrong dispatch waiting to happen — reject/reassign, don't just hope it fits.
- What's the scheduling discipline? FCFS is the trap above. The next section derives LOOK — the same idea disk schedulers use to minimize seek time: keep moving one direction, service everything along the way, reverse only once there's nothing left ahead.
- Emergency / maintenance mode? Fire alarm: every car should return to a designated floor and stop accepting calls, overriding whatever it was doing. Maintenance: a car should be removable from the dispatcher's pool without special-casing every call site. Both are usually "mention you'd model this as another state, don't build it unless asked" — a live example of when not to over-engineer the base kata.
Answer for this kata: N cars (configurable, default 1 for M1–M3), hall calls (floor + direction) and car calls (floor only) both modeled as the same underlying "stop," a per-car capacity limit, LOOK scheduling, and emergency/maintenance flagged as a named extension rather than built.
Reason to the design
The state you actually need: a car has a Direction — UP, DOWN, or IDLE — and that direction is exactly what should decide which request gets served next. It's the State Pattern in its simplest honest form: three states, clear transitions (IDLE→UP/DOWN when a stop is added; UP/DOWN→IDLE when both stop sets empty; UP↔DOWN when the current sweep runs dry but the other direction still has work).
Simplest thing that could work: one shared queue of destination floors, FCFS — exactly the trap above.
Why it fails: already shown. It ignores the car's current position and direction entirely, so it can't tell "this request is 2 floors away, on my way" from "this request is on the opposite side of the building." Any scheduler that doesn't use position + direction as its primary signal will ping-pong.
The fix — sort by direction, not by arrival time: keep two sorted collections per car — upStops and downStops — instead of one FIFO queue. When the car is heading UP, it always serves its nearest unserved floor above next, not whichever request happened to arrive first. This is the disk-scheduling analogy made concrete: SCAN ("the elevator algorithm" in OS textbooks) sweeps from one physical end to the other, servicing every pending request, reversing only at the edge. LOOK is the practical refinement — it reverses the instant there's nothing left requested ahead of it, without traveling all the way to the top floor if the highest requested floor is 9 out of a 20-floor building. LOOK is strictly no worse than SCAN, and strictly better whenever requests don't span the full range — in real buildings, that's basically always. This kata builds LOOK.
Hall calls vs. car calls, mechanically: a car call (rider already inside, pressed "12") always becomes a stop in whichever set matches its position relative to the car — the rider is already committed to this car, no judgment needed. A hall call (rider on floor 7 pressed ▼) is different in kind: it should ideally only land on a car that is already heading that direction and hasn't passed that floor yet — that decision belongs to the Dispatcher (M4), which scores every candidate car via etaTo(floor, direction) rather than blindly handing the stop to whichever car happens to be nearest in raw distance.
Build it — milestones
- M1 — One car, two sorted stop sets.
addStop(floor),tick(), single-threaded. No optimization yet — just prove a car can serve requests floor-by-floor and stop on a match. - M2 — LOOK scheduling. Reverse the instant the current sweep runs out of requests ahead (not at a building edge) — the SCAN→LOOK refinement. Verify it with the FCFS-vs-LOOK comparison in Break It.
- M3 — Hall calls vs. car calls + capacity. Distinguish external (floor + direction, no destination yet) from internal (destination only) requests; add per-car capacity with
tryBoard()/exit(); refuse boarding past capacity instead of silently overfilling. - M4 — N cars + a Dispatcher + thread-safety. A Dispatcher scores every car's
etaTo(floor, direction)and assigns the hall call to the lowest-ETA car with room; the whole select-then-commit is one critical section so two concurrent hall calls can't race onto the same car's last seat.
Reference implementation — Java: the Car (LOOK scheduling)
Two sorted stop sets ARE the scheduler — there's no separate "algorithm" object. tick() reverses the moment its stop set empties, which is what makes this LOOK instead of SCAN: notice there's no topFloor/bottomFloor constant anywhere. That absence is the optimization.
import java.util.NavigableSet;
import java.util.TreeSet;
import java.util.concurrent.locks.ReentrantLock;
/** One elevator car running LOOK scheduling: two sorted stop sets (up / down),
* keep moving in the current direction servicing stops, reverse only once
* there are no more stops AHEAD (not at the building's top/bottom -- that's
* what makes this LOOK instead of SCAN). */
final class Car {
enum Direction { UP, DOWN, IDLE }
final int id;
private final int capacity;
private int currentFloor;
private Direction direction = Direction.IDLE;
private int occupancy = 0;
private final NavigableSet<Integer> upStops = new TreeSet<>(); // ascending: farthest-up = last()
private final NavigableSet<Integer> downStops = new TreeSet<>(); // ascending: farthest-down = first()
private final ReentrantLock lock = new ReentrantLock();
Car(int id, int capacity, int startFloor) {
this.id = id;
this.capacity = capacity;
this.currentFloor = startFloor;
}
int currentFloor() { lock.lock(); try { return currentFloor; } finally { lock.unlock(); } }
Direction direction() { lock.lock(); try { return direction; } finally { lock.unlock(); } }
/** A rider inside the car presses a floor button, OR the Dispatcher assigns a hall call to this car. */
void addStop(int floor) {
lock.lock();
try {
if (floor > currentFloor) upStops.add(floor);
else if (floor < currentFloor) downStops.add(floor);
if (direction == Direction.IDLE && floor != currentFloor) {
direction = floor > currentFloor ? Direction.UP : Direction.DOWN;
}
} finally {
lock.unlock();
}
}
/** LOOK-aware cost estimate: floors this car must travel before it could pick up
* a hall call at `floor` heading `dir`. "On the way" is cheap; "behind" costs a
* full reversal to the current sweep's turnaround point first. */
int etaTo(int floor, Direction dir) {
lock.lock();
try {
if (direction == Direction.IDLE) return Math.abs(floor - currentFloor);
if (direction == Direction.UP && dir == Direction.UP && floor >= currentFloor) {
return floor - currentFloor;
}
if (direction == Direction.DOWN && dir == Direction.DOWN && floor <= currentFloor) {
return currentFloor - floor;
}
if (direction == Direction.UP) {
int turnaround = upStops.isEmpty() ? currentFloor : upStops.last();
return (turnaround - currentFloor) + Math.abs(turnaround - floor);
} else {
int turnaround = downStops.isEmpty() ? currentFloor : downStops.first();
return (currentFloor - turnaround) + Math.abs(turnaround - floor);
}
} finally {
lock.unlock();
}
}
boolean hasCapacity() { lock.lock(); try { return occupancy < capacity; } finally { lock.unlock(); } }
/** Capacity check-and-increment as ONE atomic step under the lock -- see the break-it test for why. */
boolean tryBoard() {
lock.lock();
try {
if (occupancy >= capacity) return false;
occupancy++;
return true;
} finally {
lock.unlock();
}
}
void exit() { lock.lock(); try { occupancy = Math.max(0, occupancy - 1); } finally { lock.unlock(); } }
int occupancy() { lock.lock(); try { return occupancy; } finally { lock.unlock(); } }
/** One LOOK tick: advance exactly one floor toward `direction`, service a stop if
* reached, and reverse at the LAST requested stop (not the building's edge --
* that's the whole saving LOOK has over SCAN). Returns the serviced floor, or -1. */
int tick() {
lock.lock();
try {
if (direction == Direction.UP) {
if (upStops.isEmpty()) {
direction = downStops.isEmpty() ? Direction.IDLE : Direction.DOWN;
return -1;
}
currentFloor++;
return upStops.remove(currentFloor) ? currentFloor : -1;
} else if (direction == Direction.DOWN) {
if (downStops.isEmpty()) {
direction = upStops.isEmpty() ? Direction.IDLE : Direction.UP;
return -1;
}
currentFloor--;
return downStops.remove(currentFloor) ? currentFloor : -1;
}
return -1;
} finally {
lock.unlock();
}
}
}
Reference implementation — Java: the Dispatcher (thread-safe multi-car assignment)
import java.util.List;
/** Assigns each hall call to the nearest suitable car. The whole
* select-then-commit (read every car's ETA, pick the best, addStop on it)
* is ONE critical section -- split it into two steps and two hall calls
* can race onto the same "best" car's last seat (see the break-it test). */
final class Dispatcher {
private final List<Car> cars;
private final Object assignLock = new Object();
Dispatcher(List<Car> cars) {
this.cars = cars;
}
int assign(int floor, Car.Direction dir) {
synchronized (assignLock) {
Car best = null;
int bestEta = Integer.MAX_VALUE;
for (Car c : cars) {
if (!c.hasCapacity()) continue;
int eta = c.etaTo(floor, dir);
if (eta < bestEta) {
bestEta = eta;
best = c;
}
}
if (best == null) throw new IllegalStateException("no car has capacity");
best.addStop(floor);
return best.id;
}
}
}
Reference implementation — Go: Car + Dispatcher
Same shape as the Java version, one sync.Mutex per car for stop-set mutation and a separate dispatchMu in the Dispatcher guarding only the select-then-commit sequence.
package main
import "sync"
type Direction int
const (
Idle Direction = iota
Up
Down
)
// Car is one elevator car running LOOK scheduling: two stop sets (up/down),
// keep moving in the current direction servicing stops, reverse only once
// there are no more stops AHEAD -- not at the building's top/bottom (that's
// what makes this LOOK instead of SCAN).
type Car struct {
ID int
capacity int
mu sync.Mutex
floor int
direction Direction
occupancy int
upStops map[int]bool
downStops map[int]bool
}
func NewCar(id, capacity, startFloor int) *Car {
return &Car{
ID: id,
capacity: capacity,
floor: startFloor,
direction: Idle,
upStops: make(map[int]bool),
downStops: make(map[int]bool),
}
}
func (c *Car) CurrentFloor() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.floor
}
func (c *Car) DirectionNow() Direction {
c.mu.Lock()
defer c.mu.Unlock()
return c.direction
}
// AddStop registers a floor request: a rider's car button, or a hall call the Dispatcher assigned here.
func (c *Car) AddStop(floor int) {
c.mu.Lock()
defer c.mu.Unlock()
switch {
case floor > c.floor:
c.upStops[floor] = true
case floor < c.floor:
c.downStops[floor] = true
}
if c.direction == Idle && floor != c.floor {
if floor > c.floor {
c.direction = Up
} else {
c.direction = Down
}
}
}
func abs(x int) int {
if x < 0 {
return -x
}
return x
}
func (c *Car) upTurnaround() int {
max := c.floor
for f := range c.upStops {
if f > max {
max = f
}
}
return max
}
func (c *Car) downTurnaround() int {
min := c.floor
first := true
for f := range c.downStops {
if first || f < min {
min = f
first = false
}
}
return min
}
// EtaTo estimates floors-to-travel before this car could serve a hall call at
// `floor` heading `dir` -- LOOK-aware: "on the way" is cheap, "behind" costs a full reversal first.
func (c *Car) EtaTo(floor int, dir Direction) int {
c.mu.Lock()
defer c.mu.Unlock()
if c.direction == Idle {
return abs(floor - c.floor)
}
if c.direction == Up && dir == Up && floor >= c.floor {
return floor - c.floor
}
if c.direction == Down && dir == Down && floor <= c.floor {
return c.floor - floor
}
if c.direction == Up {
t := c.upTurnaround()
return (t - c.floor) + abs(t-floor)
}
t := c.downTurnaround()
return (c.floor - t) + abs(t-floor)
}
func (c *Car) HasCapacity() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.occupancy < c.capacity
}
// TryBoard does the capacity check-and-increment as ONE atomic step under the lock.
func (c *Car) TryBoard() bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.occupancy >= c.capacity {
return false
}
c.occupancy++
return true
}
func (c *Car) Exit() {
c.mu.Lock()
defer c.mu.Unlock()
if c.occupancy > 0 {
c.occupancy--
}
}
func (c *Car) Occupancy() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.occupancy
}
// Tick advances exactly one floor toward the current direction, servicing a
// stop if reached, and reverses at the LAST requested stop (not the
// building's edge -- LOOK's saving over SCAN). Returns the serviced floor, or -1.
func (c *Car) Tick() int {
c.mu.Lock()
defer c.mu.Unlock()
switch c.direction {
case Up:
if len(c.upStops) == 0 {
if len(c.downStops) == 0 {
c.direction = Idle
} else {
c.direction = Down
}
return -1
}
c.floor++
if c.upStops[c.floor] {
delete(c.upStops, c.floor)
return c.floor
}
return -1
case Down:
if len(c.downStops) == 0 {
if len(c.upStops) == 0 {
c.direction = Idle
} else {
c.direction = Up
}
return -1
}
c.floor--
if c.downStops[c.floor] {
delete(c.downStops, c.floor)
return c.floor
}
return -1
default:
return -1
}
}
// Dispatcher assigns a hall call to the best (lowest-ETA) car with room. The
// whole select-then-commit is ONE critical section under dispatchMu -- that's
// what prevents two hall calls from racing onto the same car's last seat
// based on two stale "has capacity" reads (see the break-it test).
type Dispatcher struct {
cars []*Car
dispatchMu sync.Mutex
}
func NewDispatcher(cars []*Car) *Dispatcher {
return &Dispatcher{cars: cars}
}
func (d *Dispatcher) Assign(floor int, dir Direction) (int, bool) {
d.dispatchMu.Lock()
defer d.dispatchMu.Unlock()
bestID := -1
bestEta := 1 << 30
var bestCar *Car
for _, c := range d.cars {
if !c.HasCapacity() {
continue
}
eta := c.EtaTo(floor, dir)
if eta < bestEta {
bestEta = eta
bestCar = c
bestID = c.ID
}
}
if bestCar == nil {
return -1, false
}
bestCar.AddStop(floor)
return bestID, true
}
Break it
Two failures, both real and both measured in this session.
1 — FCFS vs. LOOK, the same request set. Feed the identical requests from The Trap (5, 1, 9, 2, 8, car starting at floor 0) into a plain arrival-order sum and into a real Car running LOOK, and count total floors traveled by each:
2 — the capacity race. tryBoard() on the real Car does "check occupancy, then increment" as one locked step. Split that into two unsynchronized steps (a Thread.yield()/runtime.Gosched() planted deliberately in between to widen the window) and race 200 threads for a single seat:
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
/** Same idea as Car.tryBoard(), but the capacity check and the increment are
* NOT one atomic step -- two threads can both pass the check before either
* increments. That gap is the bug. */
final class UnsafeCar {
private final int capacity;
private volatile int occupancy = 0;
UnsafeCar(int capacity) { this.capacity = capacity; }
boolean tryBoard() {
if (occupancy >= capacity) return false; // CHECK
Thread.yield(); // widen the race window so it shows up every run
occupancy++; // ...then ACT -- another thread can slip in right here
return true;
}
int occupancy() { return occupancy; }
}
final class ElevatorBreakIt {
public static void main(String[] args) throws InterruptedException {
// ---- Part 1: FCFS vs LOOK -- same request set, two schedulers, real numbers ----
int[] requests = {5, 1, 9, 2, 8}; // arrival order; car starts at floor 0
int fcfsDistance = 0, at = 0;
for (int r : requests) {
fcfsDistance += Math.abs(r - at);
at = r;
}
// FCFS services strictly in arrival order: 0->5->1->9->2->8 -- ping-pongs across the shaft.
Car look = new Car(1, Integer.MAX_VALUE, 0);
for (int r : requests) look.addStop(r);
int lookDistance = 0;
while (look.direction() != Car.Direction.IDLE) {
int before = look.currentFloor();
look.tick();
lookDistance += Math.abs(look.currentFloor() - before);
}
// LOOK sorts the same 5 requests into one monotonic upward sweep: 0->1->2->5->8->9.
System.out.println("FCFS total floors traveled = " + fcfsDistance);
System.out.println("LOOK total floors traveled = " + lookDistance);
if (fcfsDistance != 30) throw new AssertionError("expected FCFS=30, got " + fcfsDistance);
if (lookDistance != 9) throw new AssertionError("expected LOOK=9, got " + lookDistance);
System.out.println("FCFS travels " + (fcfsDistance - lookDistance) + " extra floors for the identical request set.");
// ---- Part 2: the capacity race -- 200 threads racing for ONE seat ----
Car safe = new Car(2, 1, 0); // capacity = 1
AtomicInteger safeAccepted = new AtomicInteger();
race(200, safe::tryBoard, safeAccepted);
System.out.println("Locked Car: accepted=" + safeAccepted.get() + " (capacity=1)");
if (safeAccepted.get() != 1) throw new AssertionError("locked car should accept exactly 1, got " + safeAccepted.get());
UnsafeCar unsafe = new UnsafeCar(1);
AtomicInteger unsafeAccepted = new AtomicInteger();
race(200, unsafe::tryBoard, unsafeAccepted);
System.out.println("Unlocked UnsafeCar: accepted=" + unsafeAccepted.get() + " (capacity=1) -- THE BUG: more than 1 rider boarded a 1-seat car.");
if (unsafeAccepted.get() <= 1) {
System.out.println("(race didn't trigger this run -- rerun; the check-then-act gap is still there)");
}
}
interface BoolCall { boolean call(); }
private static void race(int n, BoolCall attempt, AtomicInteger accepted) throws InterruptedException {
CountDownLatch start = new CountDownLatch(1);
Thread[] threads = new Thread[n];
for (int i = 0; i < n; i++) {
threads[i] = new Thread(() -> {
try { start.await(); } catch (InterruptedException ignored) { }
if (attempt.call()) accepted.incrementAndGet();
});
threads[i].start();
}
start.countDown();
for (Thread t : threads) t.join();
}
}
Measured, this session (javac -Xlint:all -Werror, run three times): FCFS total floors traveled = 30, LOOK total floors traveled = 9 — every run, deterministic. Locked Car: accepted=1 every run. Unlocked UnsafeCar: accepted=2, then 3, then 3 across three separate runs — more than one rider boarded a car with exactly one seat, every single time. Same shape in Go (below): go vet clean, go build -race clean — and the race detector reports no data race on the unsafe version, because every access to occupancy uses atomic.LoadInt32/AddInt32. That's the sharpest part of the lesson: -race only catches unsynchronized memory access, not a broken check-then-act invariant. The memory is safe; the logic still overbooks the car. Atomics are not a substitute for a lock around a multi-step decision.
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
// UnsafeCar mirrors Car.TryBoard, but the capacity check and the increment
// are NOT one atomic step -- two goroutines can both pass the check before
// either increments. That gap is the bug.
type UnsafeCar struct {
capacity int
occupancy int32
}
func (u *UnsafeCar) TryBoard() bool {
if int(atomic.LoadInt32(&u.occupancy)) >= u.capacity { // CHECK
return false
}
runtime.Gosched() // widen the race window so it shows up every run
atomic.AddInt32(&u.occupancy, 1) // ...then ACT -- another goroutine can slip in right here
return true
}
func race(n int, attempt func() bool) int32 {
var accepted int32
var wg sync.WaitGroup
start := make(chan struct{})
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
<-start
if attempt() {
atomic.AddInt32(&accepted, 1)
}
}()
}
close(start)
wg.Wait()
return accepted
}
func main() {
// ---- Part 1: FCFS vs LOOK -- same request set, two schedulers, real numbers ----
requests := []int{5, 1, 9, 2, 8} // arrival order; car starts at floor 0
fcfsDistance, at := 0, 0
for _, r := range requests {
fcfsDistance += abs(r - at)
at = r
}
// FCFS services strictly in arrival order: 0->5->1->9->2->8 -- ping-pongs across the shaft.
look := NewCar(1, 1<<30, 0)
for _, r := range requests {
look.AddStop(r)
}
lookDistance := 0
for look.DirectionNow() != Idle {
before := look.CurrentFloor()
look.Tick()
lookDistance += abs(look.CurrentFloor() - before)
}
// LOOK sorts the same 5 requests into one monotonic upward sweep: 0->1->2->5->8->9.
fmt.Println("FCFS total floors traveled =", fcfsDistance)
fmt.Println("LOOK total floors traveled =", lookDistance)
if fcfsDistance != 30 {
panic(fmt.Sprintf("expected FCFS=30, got %d", fcfsDistance))
}
if lookDistance != 9 {
panic(fmt.Sprintf("expected LOOK=9, got %d", lookDistance))
}
fmt.Println("FCFS travels", fcfsDistance-lookDistance, "extra floors for the identical request set.")
// ---- Part 2: the capacity race -- 200 goroutines racing for ONE seat ----
safe := NewCar(2, 1, 0) // capacity = 1
safeAccepted := race(200, safe.TryBoard)
fmt.Println("Locked Car: accepted =", safeAccepted, "(capacity=1)")
if safeAccepted != 1 {
panic(fmt.Sprintf("locked car should accept exactly 1, got %d", safeAccepted))
}
unsafeCar := &UnsafeCar{capacity: 1}
unsafeAccepted := race(200, unsafeCar.TryBoard)
fmt.Println("Unlocked UnsafeCar: accepted =", unsafeAccepted, "(capacity=1) -- THE BUG: more than 1 rider boarded a 1-seat car.")
}
Tests to write (this is the real learning)
- State transitions: a fresh car reports
IDLE; afteraddStop(5)it reportsUP; after draining (repeatedtick()until empty) it's back toIDLEat floor 5. Proves the three-state machine. - Reversal at the last stop, not a building edge: car at floor 0, stops
{3, 5}only — total floors traveled to drain = 5, and the car ends at floor 5, never having modeled or needed a "top floor" at all. That absence of a boundary constant is what makes this LOOK, not SCAN. - Capacity: a capacity-2 car — 2
tryBoard()calls succeed, a 3rd returnsfalse; callexit()once, and a 4thtryBoard()succeeds again. Concrete, measured in this session. - Dispatcher picks the LOOK-aware nearest car, not raw distance: Car A idle at floor 0 (
etaTo(7, DOWN) == 7). Car B already heading DOWN from floor 10 with a stop registered at floor 6 (etaTo(7, DOWN) == 3, because floor 7 is directly on its current sweep). A hall call at floor 7 going DOWN is assigned to Car B — measured, this session — even though Car A starts closer to floor 7 in raw distance (7 vs. 10). ETA, not distance, is the correct metric. - FCFS vs. LOOK (the trap, proven): the fixed request set
{5, 1, 9, 2, 8}from floor 0 — FCFS travels exactly 30 floors, LOOK travels exactly 9. Same requests, same car, 3.3× the travel for the naive scheduler. - The race (the real lesson): capacity-1 car, 200 threads/goroutines calling
tryBoard()concurrently — assert exactly 1 succeeds on the real, lockedCar. Swap inUnsafeCar(check and increment split into two steps) and rerun: 2–8 succeed depending on scheduling luck, every run in this session. That gap between "exactly 1" and "however many the scheduler let through" is the whole point of the exercise.
Optimise — trade-offs
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Scheduling discipline | FCFS (arrival order) | LOOK (direction-sorted sweep) | Almost never at production scale — only defensible when there is provably never more than one outstanding request at a time, so there's nothing to reorder | Any real traffic pattern — always reduces total travel and bounds worst-case wait; this kata's baseline for a reason |
| Sweep policy | SCAN (reverse at the building's physical edge) | LOOK (reverse at the last requested stop) | Marginal implementation simplicity if you already track a fixed floor range and don't want to compute "farthest pending stop" — rarely worth it | Requests don't span the full building range — true almost always in practice, so LOOK dominates: it never pays for travel nobody asked for |
| Next-request choice | LOOK (serve in direction order) | SSTF — shortest-seek-time-first (always serve the nearest pending request, regardless of direction) | Worst-case wait matters — LOOK guarantees every pending stop is reached within one full sweep, no request waits forever | Minimizing the very next response time matters more than fairness — but SSTF can starve a far request indefinitely if near requests keep arriving, the same starvation trap as FCFS in a different disguise |
| Locking granularity | One global dispatcher-wide lock | Per-car lock + a separate, narrow dispatch-assignment lock (this kata's design) | Car count is small (a handful) and assignment is infrequent relative to car activity — contention is negligible, and one lock is simpler to reason about | Many cars, or frequent ticking/boarding — a global lock would serialize unrelated cars' internal state changes against every dispatch decision; per-car locks let Car A's tick() run while Car B is being scored |
| Dispatch policy | Fixed nearest-ETA (this kata) | Pluggable Strategy (zone-based, load-balanced, destination-dispatch) | Small buildings, simple traffic — nearest-ETA is a good default and needs no extra machinery | Large buildings / destination-dispatch systems (rider keys in a destination floor before boarding) — the optimal assignment groups riders by destination to minimize total system-wide stops, not just per-request ETA; swap the policy without touching Car/Dispatcher plumbing, same idea as the Parking Lot kata's pluggable FareStrategy |
Defend under drilling
- "How do you pick which car answers a hall call?" — Score every car with capacity via
etaTo(floor, direction), LOOK-aware: cheap if the floor is on the car's current sweep in the same direction, expensive (finish the sweep, reverse, then travel) otherwise. Assign to the lowest ETA. This is why Car B (heading down, already past floor 10, with a stop at 6) beats Car A (idle at floor 0) for a hall call at floor 7 despite Car A starting numerically closer — raw distance ignores direction and commitment; ETA doesn't. - "LOOK vs. SCAN — what's the actual difference?" — Both sweep in one direction servicing requests before reversing. SCAN reverses only at the building's physical edge, even if the last request was floors away from it. LOOK reverses the moment there's nothing left requested ahead. LOOK's total travel is never worse than SCAN's, and strictly better whenever requests don't span the full range — which in any real building, they don't.
- "How does this scale to a building with 50 elevators?" — A single global dispatcher lock becomes the bottleneck first — every hall call serializes through one critical section scoring 50 cars. Shard by zone: a bank of cars covering floors 1–20, another for 21–40, each with its own dispatcher; requests near a zone boundary need a cheap tie-break (score both zones' best candidate, pick the winner) rather than one shared lock across all 50. At real scale, add destination dispatch: riders key in their destination floor at a hall keypad before boarding, letting the dispatcher group compatible destinations onto the same car up front instead of discovering the conflict after boarding — this cuts total stops system-wide, not just per-request ETA.
- "Can LOOK still starve someone?" — In the adversarial case, yes: if new requests keep appearing just ahead of the car in its current direction, it can in theory sweep that direction indefinitely and never reverse toward a request behind it. Real systems bound this with an aging rule — a request that's waited past a threshold gets bumped to the front of consideration regardless of ETA. Worth naming even though this kata doesn't build it — it's the natural next question after "can LOOK starve someone."
- "Your race passed
go build -raceclean on the buggy version — doesn't that mean it's safe?" — No, and that's the trap in the question.-raceonly detects unsynchronized memory access;UnsafeCarusesatomic.LoadInt32/AddInt32for every touch ofoccupancy, so there's no memory race to report. But the invariant — "check, then act, as one indivisible decision" — is still broken, because another goroutine's atomic increment can land between this goroutine's atomic load and its own atomic increment. Atomics make individual reads/writes safe; they don't make a multi-step decision atomic. That's what the lock is actually for.
You can now defend
- You can model an elevator's state machine (
IDLE/UP/DOWN) and derive LOOK scheduling from first principles — sort by direction, not arrival order — and explain exactly why FCFS ping-pongs and starves. - You've measured LOOK against FCFS on the same request set (9 floors vs. 30) and can connect that 3.3× gap directly to "why does the interviewer care about the scheduling algorithm."
- You've built a thread-safe Dispatcher that scores N cars by LOOK-aware ETA (not raw distance) and committed the select-then-assign as one critical section — and you've broken that guarantee on purpose to show what "more riders than seats" looks like when the lock is missing.
- You can name the difference between a memory race (what
-race/-Xlintcatch) and a logic race on a multi-step invariant (what only a correctly-scoped lock catches) — and you can scale the whole design to zoned dispatch and destination-dispatch for a 50-car building.
Re-authored/Deepened for this guide. Theory: State Pattern and Strategy Pattern — UML, Code & When to Use. Reference implementations (Java + Go) and both break-it demonstrations compiled and run in this session: javac -Xlint:all -Werror clean (state, capacity, dispatcher-selection, FCFS-vs-LOOK, and race assertions all pass, verified across repeated runs); go vet + go build -race clean, same assertions pass — the race detector correctly reports no memory race on the check-then-act bug, which is itself part of the lesson.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design an Elevator System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design an Elevator System** (Hands-On Builds) and want to truly understand it. Explain Design an Elevator System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design an Elevator System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design an Elevator System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design an Elevator System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.