Problem 01 · Complete walkthrough•Intermediate•35 min

Elevator System

Design a system that receives elevator requests, chooses a car, and moves each car one floor at a time until every request is served.

A ten-floor building with three elevator cars and a passenger call being assigned to one car
First understand the real flow: a passenger makes a request, the system chooses a car, and that car moves to serve it.

1. Requirements (~5 minutes)

The prompt is intentionally short:

“Design an elevator system for a building.”

The word elevator may immediately make you think of buttons, doors, displays, motors, and sensors. That is useful real-world knowledge, but it is too much to design at once. Before choosing classes, we need to discover which small part of the real system the interviewer wants us to model.

Think of the requirements as a promise: what must our finished code be able to demonstrate? Every later class and method should help keep that promise.

Use these four question groups every time

1
Actions

What must a user be able to do?

2
Rules

When does an action succeed, fail, or change state?

3
Errors

Which invalid actions must we reject?

4
Boundaries

What should we deliberately not build?

Ask these questions

AskHow many floors and elevators should we support? Should these values be fixed?

Interviewer answer

Use three elevators and floors 0 through 9. They can be fixed for now.

AskWhat can a passenger do outside and inside an elevator?

Interviewer answer

Outside, a passenger requests UP or DOWN. Inside, a passenger selects a destination floor.

AskWhen should a moving elevator stop for a hall call?

Interviewer answer

Serve a hall call only when the car is moving in the requested direction. Destinations are always served.

AskHow does time move in this design? Are we controlling real hardware?

Interviewer answer

Build a simulation. One tick moves each active elevator by at most one floor.

AskWhat should happen for an invalid floor, a duplicate request, or the current floor?

Interviewer answer

Reject invalid floors, ignore duplicates, and treat the current floor as already served.

AskShould we include doors, weight limits, emergencies, or real concurrency?

Interviewer answer

No. Discuss them only as later extensions.

Confirm the specification

Confirmed specification

7 requirements · 5 boundaries

The first version must

  1. 1.Manage 3 elevators serving floors 0 through 9.
  2. 2.Accept UP or DOWN hall calls and assign one elevator.
  3. 3.Accept one or more destination floors inside each elevator.
  4. 4.Advance the simulation one tick at a time.
  5. 5.Serve a hall call only while moving in its requested direction.
  6. 6.Reject out-of-range floors without changing state.
  7. 7.Ignore duplicate requests and treat the current floor as already served.
Not building in this version5 boundaries
  • Doors and physical sensors
  • Weight and passenger capacity
  • Emergency and maintenance modes
  • Persistence or networking
  • Real concurrent threads

2. Entities and relationships (~3 minutes)

We have agreed on the behavior. Now we can ask: which objects are needed to make that behavior happen?

Start by scanning the confirmed requirements for important nouns: elevator, floor, request, direction, and system. These are only candidates. A noun does not automatically deserve a class.

For each candidate, ask two simple questions:

  1. Does it remember information that changes while the program runs?
  2. Does it protect or perform an important rule?

If the answer to both is no, it is probably a field, an enum, or something we can leave out.

Final model

Types we will create

Class
Owns changing state or coordinates rules.
Record
A Java record for validated, immutable data with value equality.
Interface
Defines behavior that another implementation can replace.
ElevatorClass

Owns one car's movement state and pending requests.

ElevatorControllerClass

Coordinates hall calls and the fleet.

RequestRecord

Keeps a floor request and its validated type together.

DispatchStrategyInterface

Keeps the fleet-selection rule replaceable.

Enums

Enums name a small, fixed set of valid choices or outcomes.

Direction

Names whether a car moves UP, DOWN, or is IDLE.

RequestType

Distinguishes hall pickups from inside destinations.

Fields

Fields are simple values stored inside another type; they do not need independent behavior in this version.

floorNumber

Identifies a requested or current building level.

See the objects working together

Swipe to follow the flow →

A passenger sends a hall call to ElevatorController, which selects an elevator that stores a request and reports its floor and direction
Follow one hall call from left to right. The return arrow matters: the controller needs each car's floor and direction before it can choose well.

Read the arrows as a short story. A passenger creates a hall call. The controller compares the elevators and selects one car. That car stores the request. Before the next selection, the controller can read each car's floor and direction. This explains why the controller needs access to the fleet but should not own a car's movement logic.

3. Class design (10–15 minutes)

We now have the objects, but not their fields or methods. Do not fill the classes from memory. Go back to each requirement and ask, what must this object remember, and what must another object ask it to do?

This creates a traceable chain:

requirement → responsible object → state it remembers → method that changes or reads that state

One class at a time

Design the state owners

Start with the coordinator, then follow the request into the classes that protect changing state. Select a class to see only the fields and behavior needed for its responsibility.

Class 1 of 2

ElevatorController

Coordinates hall calls and advances the fleet without calculating a car's movement itself.

Requirement behind this class

A passenger can request a car, a replaceable rule selects one elevator, and the system advances predictably.

State it remembers

Private information required to perform this class's job.

int minFloor, maxFloor

Validates every hall call and destination.

List<Elevator> elevators

Stores the fleet compared for hall calls.

DispatchStrategy dispatchStrategy

Delegates the replaceable car-selection rule.

Behavior it owns

Public use cases and internal rules kept beside that state.

requestElevator(floor, direction)Public API

Creates a hall Request, selects a car, and adds the stop.

selectDestination(elevatorId, floor)Public API

Adds a validated inside-car destination to one known Elevator.

tick()Public API

Asks every Elevator to advance one deterministic step.

getElevators()Public API

Exposes an unmodifiable fleet view for display and tests.

findElevator(...) / isValidFloor(...) Internal

Keep lookup and building-bound checks inside the controller.

Invariant it protects
Every accepted hall call is assigned through the configured Strategy to a known Elevator.
Works with
Elevator, Request, and DispatchStrategy.
Does not own
A car's current floor, direction, stop ordering, or movement rules.
Principle demonstrated
Strategy and composition separate fleet coordination from car movement and selection policy.

Supporting types

These types carry stable data or define replaceable behavior. They support the state owners without becoming another place to store live workflow state.

RequestRecord

Carries a validated floor and request type.

Generated equality lets the pending set remove duplicate requests naturally.

DispatchStrategyInterface

Defines selection from the fleet for one hall call.

The controller depends on the contract while Elevator remains unchanged when dispatch policy varies.

DirectionAwareDispatchStrategyImplementation

Prefers nearby cars already moving toward the caller.

It only selects; it does not mutate a car or store fleet state.

Decide whether a pattern helps

Only now should we discuss patterns. Look for a rule that may have more than one valid version. In this problem, car selection may change while the meaning of an elevator remains the same.

Use: Strategy for elevator selection

The rule for choosing an elevator can change independently from movement. We may start with “nearest car” and later use “direction-aware” or “lowest estimated wait.” A DispatchStrategy interface lets the controller use any of those rules without changing Elevator.

Do not force: Singleton for the controller

Nothing in the requirements says only one controller object may exist in the entire program. A singleton would add global state and make tests harder. Create a normal controller object instead.

Do not force: Factory for requests

Creating a request needs only a floor and type. A constructor is already clear. A factory would add another class without solving a problem.

4. Implementation (~10 minutes)

Below is the complete Java version. Each file has one job. The code uses Java 17 or later.

What the code does

It accepts hall calls and destination requests, chooses an elevator, and advances every car by one predictable tick. Each car decides whether to stop, continue, reverse, or become idle.

Why it is structured this way

The controller owns work that concerns the whole building. Each Elevator owns only its own position, direction, and pending stops. The selection rule is behind an interface because interviewers often ask to replace that rule.

Follow one request through the code

  1. 1ElevatorController.requestElevator receives a floor and direction.
  2. 2It creates a typed Request and asks DispatchStrategy to select one car.
  3. 3The selected Elevator stores the request after validating the floor.
  4. 4Each tick lets every Elevator serve, move, reverse, or become idle.

Code concepts to understand

Open these explanations before reading the related files. They explain the decision, not just the definition.

Strategy pattern: choosing an elevatorThe controller delegates the replaceable car-selection rule to DispatchStrategy.

The changing question is: which elevator should receive this hall call? ElevatorController should not contain every possible answer to that question.

  • DispatchStrategy defines one operation: select(elevators, hallCall).
  • DirectionAwareDispatchStrategy supplies the current rule.
  • The controller uses the interface, so it does not know how the score is calculated.
  • A nearest-car or least-busy strategy can be added without changing Elevator or the request workflow.

This pattern is useful here because the selection rule is likely to change. It is not used for movement because this version has only one movement rule.

Immutable request valueRequest records what was asked without owning system behavior.

Request has final fields and no setters. Once created, its floor and type cannot change. That makes queued requests safe to compare and easy to reason about.

equals and hashCode use the same fields because requests are stored in a Set. Two requests with the same floor and type are treated as duplicates.

Encapsulation: each object protects its own stateThe controller coordinates cars, while an Elevator alone changes its floor, direction, and queue.

The controller calls addRequest and tick; it does not directly edit an elevator's fields. getRequests returns an unmodifiable view, so outside code can inspect the queue but cannot bypass validation.

This keeps one clear place for movement rules and prevents the controller from becoming a class that does everything.

Deterministic ticks instead of real timersOne tick means one small, testable unit of time.

A real elevator is concurrent and event-driven, but threads and wall-clock timers would hide the design being discussed. tick() makes every state change repeatable: a test can call it once and know exactly what should happen.

Real concurrency can be added later around the same domain rules.

Enums and request record

Start with the smallest vocabulary. Direction describes motion, RequestType explains why a car should stop, and Request combines a floor with that reason. Keeping these meanings explicit prevents unclear booleans such as isPickup or direction strings such as "up".

Direction.java
The three possible movement states
public enum Direction {
    UP,
    DOWN,
    IDLE;

    public Direction opposite() {
        return switch (this) {
            case UP -> DOWN;
            case DOWN -> UP;
            case IDLE -> IDLE;
        };
    }
}
RequestType.java
Why the elevator must stop at a floor
public enum RequestType {
    PICKUP_UP,
    PICKUP_DOWN,
    DESTINATION;

    public boolean matches(Direction direction) {
        return this == DESTINATION
                || (this == PICKUP_UP && direction == Direction.UP)
                || (this == PICKUP_DOWN && direction == Direction.DOWN);
    }
}
Request.java
An immutable floor and request type
import java.util.Objects;

public record Request(int floor, RequestType type) {
    public Request {
        type = Objects.requireNonNull(type);
    }

    @Override
    public String toString() {
        return "Request{floor=" + floor + ", type=" + type + "}";
    }
}

Elevator entity

Elevator is the main state-owning object. Read its methods in this order: the constructor establishes valid limits, addRequest protects the queue, and tick performs one state change. The private helpers answer smaller questions so the main workflow remains readable.

Elevator.java
Owns one car's state and movement rules
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;

public final class Elevator {
    private final int id;
    private final int minFloor;
    private final int maxFloor;
    private int currentFloor;
    private Direction direction;
    private final Set<Request> requests = new LinkedHashSet<>();

    public Elevator(int id, int minFloor, int maxFloor, int startFloor) {
        if (minFloor > maxFloor) {
            throw new IllegalArgumentException("minFloor must be <= maxFloor");
        }
        if (startFloor < minFloor || startFloor > maxFloor) {
            throw new IllegalArgumentException("startFloor is out of range");
        }
        this.id = id;
        this.minFloor = minFloor;
        this.maxFloor = maxFloor;
        this.currentFloor = startFloor;
        this.direction = Direction.IDLE;
    }

    public boolean addRequest(Request request) {
        if (request == null || !isValidFloor(request.floor())) {
            return false;
        }
        if (request.floor() == currentFloor) {
            return true; // already served
        }
        return requests.add(request); // false for a duplicate
    }

    public void tick() {
        if (requests.isEmpty()) {
            direction = Direction.IDLE;
            return;
        }

        if (direction == Direction.IDLE) {
            direction = directionToNearestRequest();
        }

        if (serveCurrentFloor()) {
            if (requests.isEmpty()) direction = Direction.IDLE;
            return; // stopping consumes this tick
        }

        if (!hasRequestAhead(direction)) {
            direction = direction.opposite();
            return; // reversing consumes this tick
        }

        currentFloor += direction == Direction.UP ? 1 : -1;
    }

    private boolean serveCurrentFloor() {
        return requests.removeIf(request ->
                request.floor() == currentFloor
                        && request.type().matches(direction));
    }

    private boolean hasRequestAhead(Direction travelDirection) {
        return requests.stream().anyMatch(request ->
                travelDirection == Direction.UP
                        ? request.floor() > currentFloor
                        : request.floor() < currentFloor);
    }

    private Direction directionToNearestRequest() {
        Request nearest = requests.stream()
                .min((left, right) -> {
                    int leftDistance = Math.abs(left.floor() - currentFloor);
                    int rightDistance = Math.abs(right.floor() - currentFloor);
                    int byDistance = Integer.compare(leftDistance, rightDistance);
                    return byDistance != 0
                            ? byDistance
                            : Integer.compare(left.floor(), right.floor());
                })
                .orElseThrow();

        return nearest.floor() > currentFloor
                ? Direction.UP
                : Direction.DOWN;
    }

    private boolean isValidFloor(int floor) {
        return floor >= minFloor && floor <= maxFloor;
    }

    public int getId() {
        return id;
    }

    public int getCurrentFloor() {
        return currentFloor;
    }

    public Direction getDirection() {
        return direction;
    }

    public Set<Request> getRequests() {
        return Collections.unmodifiableSet(requests);
    }
}

Selection strategy

These two files form one replaceable seam. The interface tells the controller what it may ask for. The implementation ranks cars by direction, distance, and finally ID so equal choices still produce a predictable answer.

DispatchStrategy.java
The one rule we expect to replace
import java.util.List;

public interface DispatchStrategy {
    Elevator select(List<Elevator> elevators, Request hallCall);
}
DirectionAwareDispatchStrategy.java
Prefers a nearby car moving toward the caller
import java.util.Comparator;
import java.util.List;

public final class DirectionAwareDispatchStrategy
        implements DispatchStrategy {

    @Override
    public Elevator select(List<Elevator> elevators, Request hallCall) {
        if (elevators == null || elevators.isEmpty()) {
            throw new IllegalArgumentException("At least one elevator is required");
        }
        if (hallCall == null || hallCall.type() == RequestType.DESTINATION) {
            throw new IllegalArgumentException("A hall call is required");
        }

        Direction requestedDirection = hallCall.type() == RequestType.PICKUP_UP
                ? Direction.UP
                : Direction.DOWN;

        return elevators.stream()
                .min(Comparator
                        .comparingInt((Elevator elevator) -> priority(
                                elevator, hallCall.floor(), requestedDirection))
                        .thenComparingInt(elevator -> Math.abs(
                                elevator.getCurrentFloor() - hallCall.floor()))
                        .thenComparingInt(Elevator::getId))
                .orElseThrow();
    }

    private int priority(
            Elevator elevator,
            int requestedFloor,
            Direction requestedDirection) {

        boolean movingTowardCaller =
                elevator.getDirection() == requestedDirection
                        && (requestedDirection == Direction.UP
                            ? elevator.getCurrentFloor() <= requestedFloor
                            : elevator.getCurrentFloor() >= requestedFloor);

        if (movingTowardCaller) return 0;
        if (elevator.getDirection() == Direction.IDLE) return 1;
        return 2;
    }
}

Controller and example program

ElevatorController is the entry point for building-level actions. It validates public input, translates it into domain objects, delegates selection, and advances the fleet. ElevatorDemo is intentionally small: it shows the order in which a caller creates the system, submits a request, and advances time.

ElevatorController.java
Receives system-level commands and coordinates cars
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

public final class ElevatorController {
    private final int minFloor;
    private final int maxFloor;
    private final List<Elevator> elevators;
    private final DispatchStrategy dispatchStrategy;

    public ElevatorController(
            int elevatorCount,
            int minFloor,
            int maxFloor,
            DispatchStrategy dispatchStrategy) {

        if (elevatorCount <= 0) {
            throw new IllegalArgumentException("elevatorCount must be positive");
        }
        if (minFloor > maxFloor) {
            throw new IllegalArgumentException("minFloor must be <= maxFloor");
        }

        this.minFloor = minFloor;
        this.maxFloor = maxFloor;
        this.dispatchStrategy = Objects.requireNonNull(dispatchStrategy);
        this.elevators = new ArrayList<>();

        for (int id = 1; id <= elevatorCount; id++) {
            elevators.add(new Elevator(id, minFloor, maxFloor, minFloor));
        }
    }

    public boolean requestElevator(int floor, Direction direction) {
        if (!isValidFloor(floor) || direction == null || direction == Direction.IDLE) {
            return false;
        }

        RequestType type = direction == Direction.UP
                ? RequestType.PICKUP_UP
                : RequestType.PICKUP_DOWN;
        Request hallCall = new Request(floor, type);
        Elevator selected = dispatchStrategy.select(elevators, hallCall);
        return selected.addRequest(hallCall);
    }

    public boolean selectDestination(int elevatorId, int floor) {
        Elevator elevator = findElevator(elevatorId);
        if (elevator == null || !isValidFloor(floor)) return false;
        return elevator.addRequest(new Request(floor, RequestType.DESTINATION));
    }

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

    private Elevator findElevator(int elevatorId) {
        return elevators.stream()
                .filter(elevator -> elevator.getId() == elevatorId)
                .findFirst()
                .orElse(null);
    }

    private boolean isValidFloor(int floor) {
        return floor >= minFloor && floor <= maxFloor;
    }

    public List<Elevator> getElevators() {
        return Collections.unmodifiableList(elevators);
    }
}
ElevatorDemo.java
Creates the system and walks through one request
public final class ElevatorDemo {
    public static void main(String[] args) {
        ElevatorController controller = new ElevatorController(
                3,
                0,
                9,
                new DirectionAwareDispatchStrategy());

        controller.requestElevator(5, Direction.UP);

        for (int tick = 1; tick <= 8; tick++) {
            controller.tick();
            System.out.println("Tick " + tick);
            controller.getElevators().forEach(elevator ->
                    System.out.printf(
                            "Car %d: floor=%d, direction=%s, requests=%s%n",
                            elevator.getId(),
                            elevator.getCurrentFloor(),
                            elevator.getDirection(),
                            elevator.getRequests()));
        }
    }
}

Verify the design with a real scenario

  1. 1Initial: all cars are idle at floor 0 and all request sets are empty.
  2. 2requestElevator(5, UP) creates Request(5, PICKUP_UP).
  3. 3The strategy chooses car 1 because every car has the same priority and car 1 wins the stable ID tie-break.
  4. 4Ticks 1–5 move car 1 from floor 0 to floor 5. Other cars stay idle.
  5. 5Tick 6 finds a matching UP pickup at floor 5, removes it, and makes the car IDLE.
  6. 6requestElevator(12, UP) returns false. No elevator state changes.

Use the simulation to try the same sequence. Then load the edge case and compare the two selection rules.

Try it yourself

Elevator simulator

Tick 0
Car ACar B
F9
F8
F7
F6
B ·
F5
F4
F3
F2
F1
F0
A ·

How should we choose a car?

Prefers a car already travelling toward the caller in the requested direction.

Create hall call

Car AF0 · IDLE
Queue: empty
Car BF6 · IDLE
Queue: empty

Event log

  1. Simulation ready. Add a hall call or destination.

5. Extensions (~5 minutes)

“Use a different assignment rule”

Add another DispatchStrategy implementation. The controller and elevator classes do not change. This is the exact change the Strategy pattern was introduced to support.

“Add express elevators”

Give each elevator a set of supported floors and reject unsupported destinations in addRequest. The controller must filter out cars that cannot serve the requested floor before calling the strategy.

“Requests can arrive from different threads”

The selection and assignment must happen as one protected operation; otherwise two callers can both observe the same idle car. Protect requestElevator, or place incoming hall calls in a thread-safe queue that one controller loop consumes.

“Add doors and weight limits”

This changes the state machine. Add door state and capacity only now that the requirement exists. A car should move only when doors are closed, and should reject boarding when capacity is reached.