Home › Low-Level Design › Design an Elevator System
D

Design an Elevator System

Everyone has ridden one. Almost nobody has designed one. That gap is exactly why this problem shows up in interviews. It looks friendly, then it quietly tests whether you can model state that changes over time without your code turning into a nest of boolean flags. I'll walk through it the way I'd actually run it at a whiteboard: clarify, name entities, draw the classes, write the parts that carry the design, then stress-test it with edge cases.

Low-Level DesignMedium11 min read

If you remember five things

The whole answer in one glance. Everything below defends these five calls.

  1. Two request types, not one. A hall call has a floor and a direction and no fixed car. A car call is bound to one specific car. Conflate them and the scheduler falls apart.
  2. State pattern on the car. Moving, Stopped, and Idle each own their own transition rules, so no single method has to understand every combination of car behavior at once.
  3. Strategy pattern on scheduling. Which car answers a hall call lives behind an interface, because it is the one piece that gets rewritten most.
  4. Split stops by direction. Keep upStops and downStops and drain one fully before reversing. That is the SCAN behavior that makes sweeping correct.
  5. Concurrency is one executor per car. Each car processes stops serially off its own queue, so there is no lock contention and behavior stays deterministic.

1Clarify the requirements#

I never start coding an elevator. I pin down which elevator first, because "an elevator" can mean five different machines depending on the building. The first two minutes of scoping is where I separate myself from the candidate who dives straight into a move() method.

The questions I actually ask:

  • One car or a bank of cars? A single elevator is a toy. A bank of four in an office tower is the real problem, and the interesting part is which car answers a call.
  • How does a person request a ride? Two distinct request types, and people conflate them. A hall call is the up/down button on a floor. A car call is the floor button you press once inside. They feed the scheduler differently: a hall call has a direction and no fixed car, a car call is bound to one specific car.
  • How many floors, and are there basements? Negative floor numbers trip up naive code that indexes into an array.
  • Do we care about capacity and weight? A full car should skip hall calls.
  • Any special modes? Maintenance, fire-service, VIP override. Good to name, usually out of scope unless they push.

The scope I'd commit to:

Functional:

  • Multiple cars serving a shared set of floors.
  • Accept hall calls (floor + direction) and car calls (target floor).
  • Each car moves, stops, opens and closes doors, and honors a direction while it has pending stops that way.
  • A dispatcher assigns each hall call to one car.

Non-functional:

  • Minimize passenger wait time with a sane scheduling rule.
  • Thread-safe: button presses arrive concurrently from many floors.
  • Extensible: I should be able to swap the scheduling logic without rewriting the car.
From the interviewer's chair

That last non-functional line is the one that earns points. I want to hear that the picking-a-car logic is pluggable, because in a real elevator system it is the part that gets tuned and rewritten most often. Name it before I ask and I know you have shipped something like this.

2Identify the core entities#

List the nouns, then defend each one. If I can't justify a class, I drop it.

  • Elevator (Car): the physical cabin. Owns its current floor, direction, door state, and its own queue of stops. This is the heart.
  • Direction: an enum, UP, DOWN, IDLE. Small but it drives half the logic.
  • ElevatorState: the car's behavior changes completely based on whether it's moving, stopped at a floor, or idle. This screams State pattern.
  • Request: a wrapper for a call, split into HallRequest (floor + desired direction) and CarRequest (target floor). Different data, different source.
  • Button / Panel: the hall panel on each floor and the car panel inside. They generate requests. Thin, but they model the real inputs.
  • Dispatcher (Controller): receives hall calls and decides which car takes them. The brains of a multi-car system.
  • SchedulingStrategy: the actual algorithm the dispatcher uses to pick a car. Pulled out so it can be swapped.
  • Door: open/closed, with a timed close. A small state holder inside the car.
Red flag that burns your whiteboard time

Over-modeling every physical part. Adding a Cable, Motor, and CounterWeight class nobody will ever call is a classic trap. I keep Button and Door deliberately lightweight. You win this round by modeling behavior that changes, not by cataloging hardware.

3Class design#

The shape I'm going for:

  • The Dispatcher holds the cars and a SchedulingStrategy.
  • A hall call arrives: the dispatcher asks the strategy to score every car, picks the best, then pushes the stop onto that car's queue.
  • Each Elevator runs its own step loop and delegates behavior to its current ElevatorState.

The two relationships I want visible in the diagram:

  • Strategy is composed into the dispatcher, so it's swappable.
  • State is composed into the elevator, so behavior is per-car and changes at runtime.
classDiagram class Direction { <> UP DOWN IDLE } class Request { +int targetFloor } class HallRequest { +Direction direction } class CarRequest class Elevator { -int id -int currentFloor -Direction direction -ElevatorState state -TreeSet stops +addStop(floor) void +step() void +setState(s) void } class ElevatorState { <> +step(car) void } class MovingState { +step(car) void } class StoppedState { +step(car) void } class IdleState { +step(car) void } class Door { -boolean open +open() void +close() void } class SchedulingStrategy { <> +selectCar(cars, request) Elevator } class NearestCarStrategy { +selectCar(cars, request) Elevator } class Dispatcher { -List cars -SchedulingStrategy strategy +handleHallCall(req) void +handleCarCall(carId, floor) void } Request <|-- HallRequest Request <|-- CarRequest ElevatorState <|-- MovingState ElevatorState <|-- StoppedState ElevatorState <|-- IdleState Elevator *-- ElevatorState Elevator *-- Door Elevator --> Direction Dispatcher o-- Elevator Dispatcher *-- SchedulingStrategy SchedulingStrategy <|-- NearestCarStrategy
The two composition arrows are the whole point. Dispatcher *-- SchedulingStrategy means you swap the picking-a-car algorithm without touching a car, and Elevator *-- ElevatorState means each car changes behavior at runtime. The hollow diamond on Dispatcher o-- Elevator says the dispatcher references cars it does not own.

4Key implementation#

I'll show the parts that carry the design and skip getters and constructors where they add nothing.

Start with the enum and requests. Tiny, but they set the vocabulary.

public enum Direction { UP, DOWN, IDLE }

public abstract class Request {
    protected final int targetFloor;
    protected Request(int targetFloor) { this.targetFloor = targetFloor; }
    public int getTargetFloor() { return targetFloor; }
}

public class HallRequest extends Request {
    private final Direction direction; // which way the rider wants to go
    public HallRequest(int floor, Direction direction) {
        super(floor);
        this.direction = direction;
    }
    public Direction getDirection() { return direction; }
}

public class CarRequest extends Request {
    public CarRequest(int floor) { super(floor); }
}

The Elevator is where I spend my care. It keeps stops in sorted sets so the next stop in the current direction is a log-time lookup. Note what it does not do:

  • It does not decide how to move. It asks its state to do that.
  • It splits stops into two sets so it can drain one direction fully before reversing.
  • addStop short-circuits a call for the current floor, so an idle car never latches onto IDLE as a direction.
public class Elevator {
    private final int id;
    private int currentFloor = 0;
    private Direction direction = Direction.IDLE;
    private ElevatorState state;
    private final Door door = new Door();

    // stops split by direction so we drain one way fully before reversing
    private final TreeSet<Integer> upStops = new TreeSet<>();
    private final TreeSet<Integer> downStops = new TreeSet<>();

    public Elevator(int id) {
        this.id = id;
        this.state = new IdleState();
    }

    public synchronized void addStop(int floor) {
        if (floor == currentFloor) return;
        if (floor > currentFloor) upStops.add(floor);
        else downStops.add(floor);
        if (direction == Direction.IDLE) {
            direction = floor > currentFloor ? Direction.UP : Direction.DOWN;
            setState(new MovingState());
        }
    }

    // called by the simulation clock
    public synchronized void step() { state.step(this); }

    Integer nextStop() {
        if (direction == Direction.UP) {
            Integer f = upStops.ceiling(currentFloor);
            return f != null ? f : downStops.floor(currentFloor);
        } else if (direction == Direction.DOWN) {
            Integer f = downStops.floor(currentFloor);
            return f != null ? f : upStops.ceiling(currentFloor);
        }
        return null;
    }

    void arriveAt(int floor) {
        currentFloor = floor;
        upStops.remove(floor);
        downStops.remove(floor);
    }

    boolean hasWork() { return !upStops.isEmpty() || !downStops.isEmpty(); }

    void setState(ElevatorState s) { this.state = s; }
    // id, currentFloor, direction, door accessors elided
}
Pause before you read on: the car is heading UP and you add a stop below the current floor. Why does the car not immediately turn around?
Reveal the answer
`addStop` only sets direction when the car is IDLE. A moving car just files the lower floor into `downStops` and keeps sweeping up. `nextStop` returns the `downStops.floor(...)` fallback only after `upStops.ceiling(...)` runs dry, so the car finishes its up sweep, then reverses. That is the SCAN behavior, and it is exactly why stops are split into two sets instead of one.

The states carry the behavior. Each one knows only its own transition rules:

  • Moving advances one floor toward the next stop, or flips to Stopped when it arrives.
  • Stopped opens the door, then decides keep going or go idle.
  • Idle waits for work, then wakes into Moving.
public interface ElevatorState {
    void step(Elevator car);
}

public class MovingState implements ElevatorState {
    public void step(Elevator car) {
        Integer target = car.nextStop();
        if (target == null) { car.setState(new IdleState()); return; }
        int floor = car.getCurrentFloor();
        if (floor == target) {
            car.setState(new StoppedState());
        } else {
            car.moveOneFloor(target > floor ? Direction.UP : Direction.DOWN);
        }
    }
}

public class StoppedState implements ElevatorState {
    public void step(Elevator car) {
        car.arriveAt(car.getCurrentFloor());
        car.getDoor().open();
        car.getDoor().close(); // in a real sim this is timer driven
        if (car.hasWork()) car.setState(new MovingState());
        else car.setState(new IdleState());
    }
}

public class IdleState implements ElevatorState {
    public void step(Elevator car) {
        if (car.hasWork()) car.setState(new MovingState());
    }
}

Now the dispatcher and the strategy. The dispatcher owns the cars and leans on the strategy for the one hard decision: which car answers a hall call.

  • The cost function is the only piece I expect to get rewritten, which is why it lives behind an interface.
  • Lower cost is better: an idle car scores on pure distance, a car sweeping past the call scores on distance too, and a wrong-way car gets a big penalty so it falls to the back of the line.
public interface SchedulingStrategy {
    Elevator selectCar(List<Elevator> cars, HallRequest req);
}

public class NearestCarStrategy implements SchedulingStrategy {
    public Elevator selectCar(List<Elevator> cars, HallRequest req) {
        Elevator best = null;
        int bestScore = Integer.MAX_VALUE;
        for (Elevator car : cars) {
            int score = cost(car, req);
            if (score < bestScore) { bestScore = score; best = car; }
        }
        return best;
    }

    // lower is better: prefer a car already heading toward the call
    private int cost(Elevator car, HallRequest req) {
        int distance = Math.abs(car.getCurrentFloor() - req.getTargetFloor());
        boolean sameWay = car.getDirection() == req.getDirection();
        boolean onTheWay = sameWay &&
            ((req.getDirection() == Direction.UP && car.getCurrentFloor() <= req.getTargetFloor()) ||
             (req.getDirection() == Direction.DOWN && car.getCurrentFloor() >= req.getTargetFloor()));
        if (car.getDirection() == Direction.IDLE) return distance;       // free car, pure distance
        if (onTheWay) return distance;                                   // sweeping past anyway
        return distance + 1000;                                          // penalize wrong-way cars
    }
}

public class Dispatcher {
    private final List<Elevator> cars;
    private final SchedulingStrategy strategy;

    public Dispatcher(List<Elevator> cars, SchedulingStrategy strategy) {
        this.cars = cars;
        this.strategy = strategy;
    }

    public void handleHallCall(HallRequest req) {
        Elevator car = strategy.selectCar(cars, req);
        car.addStop(req.getTargetFloor());
    }

    public void handleCarCall(int carId, int floor) {
        cars.stream().filter(c -> c.getId() == carId)
            .findFirst().ifPresent(c -> c.addStop(floor));
    }
}

The runtime flow for a single hall call, start to finish:

sequenceDiagram participant Rider participant Panel participant Dispatcher participant Strategy participant Car Rider->>Panel: press UP on floor 3 Panel->>Dispatcher: handleHallCall(3, UP) Dispatcher->>Strategy: selectCar(cars, req) Strategy-->>Dispatcher: Car 2 (lowest cost) Dispatcher->>Car: addStop(3) loop every tick Car->>Car: step() via current state end Car->>Rider: doors open at floor 3
The dispatcher asks the strategy once to pick a car, then hands off. From that point the car drives itself: every tick it calls step() and the current state decides what happens next. Notice the dispatcher never touches the car's internals, it only pushes a stop.

5Design decisions and patterns used#

The single most important call

Pulling scheduling out behind an interface is the structural choice the whole design turns on. The decision of which car takes a call is the part most likely to change, so it gets its own class. Today it's nearest-car with a wrong-way penalty. Tomorrow it's "reduce total energy" or "prioritize the executive floor," and I swap one class while nothing else moves.

State pattern, for the elevator.

  • An elevator's response to "advance one tick" is totally different when moving versus stopped versus idle.
  • You could jam that into one method with a big switch on an enum, and plenty of people do.
  • The tell that State is right: the transitions themselves (moving becomes stopped, stopped becomes idle) are part of the logic, not just the current value. Each state owns its own transition rules and reads clean.

Strategy pattern, for scheduling.

  • The cost function has a wrong-way penalty of +1000 that pushes a car heading the wrong direction to the back of the line.
  • Swapping the algorithm touches exactly one class.

Factory (lightweight), if asked.

  • A BuildingFactory that wires up N cars with a chosen strategy keeps construction in one place.
  • I'd only add it when the interviewer wants different building types.

Composition over inheritance, deliberately.

  • The elevator has a state and has a door rather than being a subclass of anything.
  • Inheritance here would be a mistake: a car is not a kind of state, and a car that must change its class to change its behavior cannot change behavior at runtime.

6Handling edge cases and concurrency#

This is where a Medium problem earns its grade. Buttons get pressed by many people at once, so the shared mutable state is real.

  • Concurrent button presses. Hall calls land on the dispatcher from different threads, and addStop mutates the car's sorted sets. I mark the mutation and step synchronized on the car as the baseline. The design I'd actually ship: move each car onto its own single-threaded executor fed by a thread-safe queue, so the car processes stops serially with no locks at all. That removes lock contention entirely and makes each car deterministic.
  • Duplicate calls. Someone mashes the up button five times. The TreeSet dedupes for free, so the fifth press is a no-op. Worth saying out loud.
  • Direction while sweeping. The classic bug: a car heading up to floor 10 should still pick up a down-rider at floor 7 only after it finishes the up sweep. Splitting stops into upStops and downStops and draining one fully before reversing is the SCAN behavior, and it is why I chose two sets instead of one.
  • Call for the floor you're on. addStop short-circuits when floor == currentFloor. Otherwise an idle car picks IDLE as its direction forever.
  • Basements. Negative floors just work because I compare integers, not unsigned counts. Naive indexing into a floors[] array breaks here: floor -2 blows up with an index out of bounds.
  • Capacity and weight. A full car should refuse new hall calls. I'd add a canAccept() check the strategy consults, so an overloaded car scores itself out of selection.
  • Door safety. Doors never close while something blocks the sensor. In the model that's a guard in Door.close() that refuses when a blocked flag is set, re-opening instead.

Split the policy from the mechanism, and every follow-up becomes a one-class change.

7Extensions and follow-ups#

Follow-ups the interviewer will ask (and your answers)
"Nearest-car is crude. Make dispatch smarter."
Replace `NearestCarStrategy` with one that estimates arrival time across the car's full remaining route, not just raw distance. It sums the stops already queued ahead of the call plus door-dwell time per stop. Same `SchedulingStrategy` interface, better numbers, zero changes to the car.
"It's a 60-floor tower at morning rush. How do you handle the load?"
Zoning. Assign cars to floor bands during rush hour so each car serves a slice of the building. That is a new strategy plus a time-of-day input the dispatcher reads. The car code does not move.
"The fire alarm goes off. What happens?"
Fire-service mode sends every car to the ground floor and locks out normal calls. I model modes as a higher-priority state the dispatcher can force onto all cars, so it overrides whatever each car was doing without special-casing the normal step loop.
"How do you track wait time and energy without touching the core?"
Observer pattern. Attach listeners to the car for wait time, trips, and energy, so analytics ride along as the car emits events. The core step loop never learns that metrics exist, which keeps it clean.
"The process restarts mid-day. Do riders lose their calls?"
On restart, reload each car's position and pending stops from durable storage before accepting new calls. Matters the moment this stops being a simulation and controls a real building.

8What the interviewer is really checking#

They already know how an elevator works. What they're watching:

  • Policy versus mechanism. Did you separate which car, in what order from move, stop, open? That's the strategy-and-state split. If it's all one class, you lose points.
  • State over time. Can you reason about behavior that changes over time without a tangle of booleans?
  • Scope first. Did you ask about multi-car before assuming single-car? The clarifying questions are scored.
  • Honest concurrency. "One executor per car" lands better than sprinkling synchronized everywhere and hoping.
  • Extensible as claimed. If you said scheduling is pluggable, the diagram had better show it plugged in.

What you can now explain in the room

  • Why hall calls and car calls are different request types and feed the scheduler differently.
  • Why the State pattern beats a boolean-and-switch car, and where the transitions live.
  • Why scheduling sits behind an interface and what a smarter cost function looks like.
  • Why stops are split into upStops and downStops, and how that produces SCAN sweeping.
  • How one executor per car removes lock contention and keeps each car deterministic.
  • How basements, duplicate presses, capacity, and door safety each get handled honestly.

If you liked the State and Strategy split here, try the Parking Lot and Vending Machine problems next, they lean on the same two patterns.

Get a prep plan built around your Design an Elevator System interview

Tell us the role and your interview date. We will send back a dated, day by day plan. Free.

Thanks. Check your inbox, your Design an Elevator System prep plan is on the way.