Back to LLD Concepts
LLD Tutorial
Medium

Elevator System Design

Design a multi-elevator system for a building — handling external hallway calls, internal cabin requests, efficient dispatch with the SCAN algorithm, and pluggable dispatch strategies. A favourite LLD interview problem with rich design pattern coverage.

OOPStrategy PatternSingletonSCAN AlgorithmThread SafetyObserver Pattern

Before coding, spend 3–5 minutes clarifying these with your interviewer. The key distinction to establish immediately is whether there is one elevator or many, and whether you need a real scheduling algorithm or a naive FIFO is sufficient.

  • Support a building with N floors and M elevators
  • Handle external requests: hallway UP / DOWN buttons on each floor
  • Handle internal requests: floor buttons inside the elevator cabin
  • Dispatch the most suitable elevator for each request
  • Each elevator maintains its own queue and moves using the SCAN algorithm
  • Thread-safe operations — multiple requests can arrive concurrently
  • Singleton ElevatorSystem — one controller per building
  • Pluggable dispatch strategy (Nearest Car, Zone-based, etc.)
  • Support MAINTENANCE mode — out-of-service elevators are skipped
💡 Interview tip: Clarify if the interviewer wants a simulation (tick-based, in-memory) or a production design(real threads, persistence, monitoring). Both are valid — just confirm up front.

Identify the real-world objects first. The elevator system has a clean separation: physical objects (Elevator), events (ElevatorRequest), and orchestrators (ElevatorSystem + DispatchStrategy).

Class / InterfaceTypeResponsibility
DirectionEnumUP · DOWN · IDLE
ElevatorStateEnumMOVING · STOPPED · MAINTENANCE
RequestTypeEnumINTERNAL (cabin) · EXTERNAL (hallway)
ElevatorRequestClassFloor + direction + type of a call
ElevatorClassPhysical car — state, queues, movement
DispatchStrategyInterfaceselectElevator(elevators, request)
NearestCarStrategyClassPick closest idle/same-direction elevator
ZoneStrategyClassAssign floors to dedicated elevators
ElevatorSystemSingletonOrchestrates all elevators + dispatch
ElevatorSystem (Singleton)
├── elevators: List<Elevator>
│   ├── upQueue:   SortedSet<Integer>   // floors to visit going UP
│   └── downQueue: SortedSet<Integer>   // floors to visit going DOWN
└── dispatchStrategy: DispatchStrategy
    ├── NearestCarStrategy   // min(|currentFloor - targetFloor|)
    └── ZoneStrategy         // floor / zoneSize → elevator index

ElevatorRequest
├── floor:     int
├── direction: Direction     // UP | DOWN | IDLE
└── type:      RequestType   // INTERNAL | EXTERNAL

There are two request paths: External (a person presses UP/DOWN in the hallway — needs dispatch) and Internal (a person inside the cabin presses a floor — no dispatch needed, goes directly to the elevator's queue).

Elevator Request LifecycleEXTERNAL (Hallway Button)Person Presses UP/DOWNElevatorSystemrequestElevator(floor, dir)DispatchStrategyselectElevator(elevators, req)ElevatorAvailable?NoAll MaintenanceYeselevator.addRequest(floor)inserted into up/down queueSCAN: elevator movestick() advances to next stopDoor Opens ✓INTERNAL (Cabin Button)Person Boards ElevatorPresses Floor ButtonselectFloor(elevatorId, floor)Look up Elevator by IDfrom elevators listelevator.addRequest(floor)No dispatch neededSCAN: elevator movesmerged into up/down queueArrives at Destinationdoor opens, floor removed from queuePerson Exits ✓── External (hallway) ────────────────────── Internal (cabin) ──

External Happy Path

Hallway button → ElevatorSystem → DispatchStrategy selects best elevator → addRequest() → SCAN moves elevator → door opens.

Internal Happy Path

Cabin button → ElevatorSystem.selectFloor() → directly calls elevator.addRequest() → SCAN moves → door opens.

All Elevators in Maintenance

DispatchStrategy throws RuntimeError / IllegalStateException. Caller must handle gracefully (e.g. display "No elevator available").

Concurrent Requests

requestElevator() and selectFloor() are both wrapped in a ReentrantLock / threading.Lock to prevent race conditions.

Draw this on the whiteboard before coding. Key relationships to highlight: ElevatorSystem composes Elevators (1..*), delegates dispatch to the DispatchStrategy interface (Strategy Pattern), and creates ElevatorRequests on each call.

UML Class Diagram — Elevator System«singleton»ElevatorSystem- elevators: List<Elevator>- totalFloors: int- dispatch: DispatchStrategy+ requestElevator(floor, dir)+ selectFloor(elevId, floor)+ tick()+ setDispatchStrategy(s)«interface»DispatchStrategy+ selectElevator(list, req)NearestCar+ selectElevator(...)ZoneStrategy- zoneSize: int+ selectElevator(...)Elevator- id: int- currentFloor: int- direction: Direction- state: ElevatorState- upQueue: SortedSet- downQueue: SortedSet+ addRequest(floor)+ step()+ costToServe(floor): int+ isIdle(): booleanElevatorRequest- floor: int- direction: Direction- type: RequestType+ getFloor()+ getDirection()+ getType()«enum»DirectionUP · DOWN · IDLE«enum»ElevatorStateMOVING · STOPPEDMAINTENANCE«enum»RequestTypeINTERNALEXTERNAL1..*usescreatesInheritance / ImplementationAssociation / DependencySingleton / EnumInterfaceConcrete Class

Solid lines = association/composition  |  Dashed lines = dependency  |  Hollow triangles = inheritance/implementation

The SCAN algorithm (also called the Elevator Algorithm, originally from disk scheduling) is the standard approach for elevator movement. Each elevator sweeps in one direction, serving all queued floors along the way, then reverses direction when there are no more requests in the current direction.

SCAN Algorithm — 3 Elevator ExampleFloor 10Floor 9Floor 8Floor 7Floor 6Floor 5Floor 4Floor 3Floor 2Floor 1Elevator 1Elevator 2Elevator 3371Elevator car (current floor)Queued stopDirection arrowDashed = floor guide

Why SCAN?

Prevents starvation — every floor eventually gets served. Simple O(log n) insert via sorted set, O(1) next-stop lookup.

vs. FIFO

FIFO (process requests in arrival order) is simpler but causes the elevator to zigzag — Floor 1 then Floor 10 then Floor 2 — wasting time.

vs. LOOK

LOOK is a variant of SCAN that reverses as soon as there are no more requests ahead, instead of going to the end. Slightly more efficient.

// SCAN example trace — Elevator starts at floor 3, going UP
// Requests: 5, 8, 10, 2, 1

Step 1: Serve 5  (already going UP, 5 > 3) ✓
Step 2: Serve 8  (continue UP)            ✓
Step 3: Serve 10 (continue UP, top)       ✓
Step 4: Reverse → going DOWN
Step 5: Serve 2  (going DOWN, 2 < 10)    ✓
Step 6: Serve 1  (continue DOWN)          ✓

// With FIFO the order would be: 5, 8, 10, 2, 1
// (same here by luck, but not always — see: 10, 2, 5, 8, 1)

Enums make the state machine explicit and prevent invalid states. The Direction enum drives SCAN's queue selection, while ElevatorState enables MAINTENANCE mode to gracefully take an elevator out of service.

// ── Enums ─────────────────────────────────────────────────────
public enum Direction   { UP, DOWN, IDLE }

public enum ElevatorState { MOVING, STOPPED, MAINTENANCE }

public enum RequestType { INTERNAL, EXTERNAL }   // button inside vs hallway

ElevatorRequest is a simple value object (or dataclass in Python) that captures everything about a call. Keeping it as an immutable record makes it safe to pass between threads without copying. The RequestType field lets the dispatcher treat internal and external calls differently if needed.

floor

The floor where the request originates

direction

UP or DOWN — where the person wants to go

type

INTERNAL (cabin) or EXTERNAL (hallway)

// ── ElevatorRequest ───────────────────────────────────────────
public class ElevatorRequest {
    private final int         floor;
    private final Direction   direction;   // which way the person wants to go
    private final RequestType type;

    public ElevatorRequest(int floor, Direction direction, RequestType type) {
        this.floor     = floor;
        this.direction = direction;
        this.type      = type;
    }

    public int         getFloor()     { return floor; }
    public Direction   getDirection() { return direction; }
    public RequestType getType()      { return type; }

    @Override
    public String toString() {
        return String.format("Request[floor=%d, dir=%s, type=%s]", floor, direction, type);
    }
}

Single Responsibility Principle

The Elevator class knows only about its own movement. It has no knowledge of the building, other elevators, or dispatch logic. It accepts requests via addRequest() and advances one step at a time via step().

The two sorted queues are the core of the SCAN implementation.upQueue stores floors to visit going up (ascending order),downQueue stores floors to visit going down (descending order).step() always picks the head of the current direction's queue, and reverses when that queue is empty.

The costToServe() method is the key hook used by the dispatch strategy — it returns the Manhattan distance between the elevator's current floor and the requested floor, providing a simple but effective fitness score.

// ── Elevator ──────────────────────────────────────────────────
import java.util.TreeSet;

public class Elevator {
    private final int       id;
    private int             currentFloor;
    private Direction       direction;
    private ElevatorState   state;
    private final int       minFloor;
    private final int       maxFloor;

    // Two sorted sets act as the up-queue and down-queue (SCAN algorithm)
    private final TreeSet<Integer> upQueue   = new TreeSet<>();   // floors to visit going up
    private final TreeSet<Integer> downQueue = new TreeSet<>(java.util.Collections.reverseOrder()); // going down

    public Elevator(int id, int minFloor, int maxFloor) {
        this.id           = id;
        this.currentFloor = minFloor;
        this.direction    = Direction.IDLE;
        this.state        = ElevatorState.STOPPED;
        this.minFloor     = minFloor;
        this.maxFloor     = maxFloor;
    }

    /** Add a stop to this elevator's queue */
    public void addRequest(int floor) {
        if (floor > currentFloor || direction == Direction.UP)
            upQueue.add(floor);
        else
            downQueue.add(floor);
    }

    /**
     * Move one step: open door at current floor if queued,
     * then advance to next floor in the SCAN order.
     */
    public void step() {
        if (state == ElevatorState.MAINTENANCE) return;

        // Serve current floor first
        if (upQueue.contains(currentFloor))   upQueue.remove(currentFloor);
        if (downQueue.contains(currentFloor)) downQueue.remove(currentFloor);

        if (!upQueue.isEmpty() && (direction == Direction.UP || downQueue.isEmpty())) {
            direction    = Direction.UP;
            state        = ElevatorState.MOVING;
            currentFloor = upQueue.first();
            System.out.println("Elevator " + id + " → floor " + currentFloor + " (UP)");
        } else if (!downQueue.isEmpty()) {
            direction    = Direction.DOWN;
            state        = ElevatorState.MOVING;
            currentFloor = downQueue.first();
            System.out.println("Elevator " + id + " → floor " + currentFloor + " (DOWN)");
        } else {
            direction = Direction.IDLE;
            state     = ElevatorState.STOPPED;
        }
    }

    public int           getId()           { return id; }
    public int           getCurrentFloor() { return currentFloor; }
    public Direction     getDirection()    { return direction; }
    public ElevatorState getState()        { return state; }
    public boolean       isIdle()          { return direction == Direction.IDLE; }

    /** Estimated cost to serve a new floor request (used by dispatcher) */
    public int costToServe(int targetFloor) {
        return Math.abs(targetFloor - currentFloor);
    }
}

Strategy Pattern

DispatchStrategy is the Strategy Pattern applied to elevator selection. Swap algorithms at runtime — switch from NearestCar during off-peak hours to ZoneStrategy during rush hour — with zero changes to ElevatorSystem.
Strategy Pattern — Pluggable DispatchElevatorSystem- dispatch: Strategy+ requestElevator(…) → dispatch.select(…)delegates«interface»DispatchStrategy+ selectElevator(list, req)NearestCarmin(costToServe)O(n) scan of elevatorsBest for small buildingsZoneStrategyfloor / zoneSize → indexO(1) lookupBest for tall buildingsCustomStrategyAny algorithm you wantML / peak-hour awareImplement & plug in

NearestCarStrategy scans all non-maintenance elevators and returns the one with the minimum costToServe(). Simple, O(n) in the number of elevators — fine for most buildings with 2–8 elevators.

ZoneStrategy pre-assigns each elevator to a floor range. Elevator 1 handles floors 1–5, Elevator 2 handles 6–10, etc. O(1) lookup. Ideal for tall buildings where minimising inter-zone travel is more important than absolute proximity.

// ── ElevatorDispatcher (Strategy Pattern) ─────────────────────
public interface DispatchStrategy {
    Elevator selectElevator(List<Elevator> elevators, ElevatorRequest request);
}

// ── Nearest Car (simplest & most common) ──────────────────────
public class NearestCarStrategy implements DispatchStrategy {
    @Override
    public Elevator selectElevator(List<Elevator> elevators, ElevatorRequest request) {
        return elevators.stream()
            .filter(e -> e.getState() != ElevatorState.MAINTENANCE)
            .min(Comparator.comparingInt(e -> e.costToServe(request.getFloor())))
            .orElseThrow(() -> new IllegalStateException("No elevators available"));
    }
}

// ── Zone Strategy (each elevator serves a floor range) ────────
public class ZoneStrategy implements DispatchStrategy {
    private final int zonesPerElevator;

    public ZoneStrategy(int totalFloors, int elevatorCount) {
        this.zonesPerElevator = totalFloors / elevatorCount;
    }

    @Override
    public Elevator selectElevator(List<Elevator> elevators, ElevatorRequest request) {
        int zoneIndex = request.getFloor() / zonesPerElevator;
        zoneIndex = Math.min(zoneIndex, elevators.size() - 1);
        Elevator zoneElevator = elevators.get(zoneIndex);
        if (zoneElevator.getState() == ElevatorState.MAINTENANCE) {
            // fallback to nearest
            return new NearestCarStrategy().selectElevator(elevators, request);
        }
        return zoneElevator;
    }
}

Thread Safety

requestElevator() and selectFloor()are both protected by a ReentrantLock (Java) or threading.Lock (Python). Without this, two threads could both evaluate the best elevator simultaneously and both assign it — causing double-assignment.
Singleton — Thread-Safe Double-Checked LockingThread 1Thread 2🔒 synchronizedOnly 1 thread entersElevatorSystem_instance (shared)✓ created onceThread 2 gets same instance

ElevatorSystem is the single entry point for the building controller. It uses double-checked locking for thread-safe singleton initialisation, and exposes only two public methods for requests: one for external (hallway) buttons, one for internal (cabin) buttons.

The tick() method advances every elevator one step. In a real system you'd replace this with a per-elevator thread that loops on its queue — the internal lock in each Elevator.step() would replace the system-level lock for better concurrency.

// ── ElevatorSystem (Singleton) ────────────────────────────────
import java.util.*;
import java.util.concurrent.locks.ReentrantLock;

public class ElevatorSystem {
    private static volatile ElevatorSystem instance;
    private final List<Elevator>    elevators;
    private final int               totalFloors;
    private DispatchStrategy        dispatchStrategy;
    private final ReentrantLock     lock = new ReentrantLock();

    private ElevatorSystem(int totalFloors, int elevatorCount) {
        this.totalFloors      = totalFloors;
        this.elevators        = new ArrayList<>();
        this.dispatchStrategy = new NearestCarStrategy();
        for (int i = 0; i < elevatorCount; i++)
            elevators.add(new Elevator(i + 1, 1, totalFloors));
    }

    public static ElevatorSystem getInstance(int floors, int elevators) {
        if (instance == null) {
            synchronized (ElevatorSystem.class) {
                if (instance == null) instance = new ElevatorSystem(floors, elevators);
            }
        }
        return instance;
    }

    public void setDispatchStrategy(DispatchStrategy s) { this.dispatchStrategy = s; }

    /** External call: person presses UP or DOWN on a floor */
    public void requestElevator(int floor, Direction direction) {
        lock.lock();
        try {
            ElevatorRequest req = new ElevatorRequest(floor, direction, RequestType.EXTERNAL);
            Elevator chosen = dispatchStrategy.selectElevator(elevators, req);
            chosen.addRequest(floor);
            System.out.printf("Dispatched Elevator %d for external request at floor %d (%s)%n",
                chosen.getId(), floor, direction);
        } finally { lock.unlock(); }
    }

    /** Internal call: person inside cabin presses a floor button */
    public void selectFloor(int elevatorId, int floor) {
        lock.lock();
        try {
            Elevator elevator = elevators.stream()
                .filter(e -> e.getId() == elevatorId)
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("Unknown elevator: " + elevatorId));
            elevator.addRequest(floor);
            System.out.printf("Elevator %d floor button: %d%n", elevatorId, floor);
        } finally { lock.unlock(); }
    }

    /** Advance all elevators one step (called in a simulation loop / scheduler) */
    public void tick() {
        elevators.forEach(Elevator::step);
    }

    public void printStatus() {
        System.out.println("=== Elevator Status ===");
        for (Elevator e : elevators) {
            System.out.printf("  Elevator %d | Floor: %2d | Dir: %-5s | State: %s%n",
                e.getId(), e.getCurrentFloor(), e.getDirection(), e.getState());
        }
    }
}

The demo creates a 20-floor building with 3 elevators, fires off 3 external requests, simulates 5 ticks of movement, then adds an internal floor request and simulates 5 more ticks.

// ── Demo ───────────────────────────────────────────────────────
public class Main {
    public static void main(String[] args) {
        ElevatorSystem system = ElevatorSystem.getInstance(20, 3);
        system.setDispatchStrategy(new NearestCarStrategy());

        // External requests from hallway buttons
        system.requestElevator(5,  Direction.UP);    // person on floor 5 wants to go up
        system.requestElevator(12, Direction.DOWN);  // person on floor 12 wants to go down
        system.requestElevator(1,  Direction.UP);    // person on floor 1 wants to go up

        // Simulate elevator movement over several ticks
        for (int i = 0; i < 5; i++) {
            system.tick();
            system.printStatus();
            System.out.println();
        }

        // Internal request — person inside Elevator 1 presses floor 8
        system.selectFloor(1, 8);

        for (int i = 0; i < 5; i++) {
            system.tick();
            system.printStatus();
            System.out.println();
        }
    }
}

Expected output (excerpt):

Dispatched Elevator 1 for external request at floor 5 (UP)

Dispatched Elevator 2 for external request at floor 12 (DOWN)

Dispatched Elevator 3 for external request at floor 1 (UP)

Elevator 1 → floor 5 (UP)

Elevator 2 → floor 12 (DOWN)

Elevator 3 → floor 1 (UP)

… (more ticks)

Elevator 1 floor button: 8

Elevator 1 → floor 8 (UP)

Elevator systems are inherently concurrent — multiple people press buttons at the same time, elevators move independently, and the controller must coordinate all of them. Here are the three key race conditions and how we prevent them:

Race: Double-Assignment

Without a lock, Thread A and Thread B both call requestElevator() simultaneously. Both evaluate NearestCarStrategy and both pick Elevator 1. Both call elevator.addRequest(floor) — now Elevator 1 has the same floor twice, wasting a trip.

✓ Fix: The system-level ReentrantLock ensures selectElevator + addRequest is atomic across all threads.

Race: Elevator State Corruption

Without per-elevator synchronization, step() running in one thread and addRequest() called from another thread could corrupt the sorted queue mid-iteration (ConcurrentModificationException in Java).

✓ Fix: In a real multi-threaded implementation, per-elevator locks (or ConcurrentSkipListSet in Java) protect the up/down queues.

Singleton Initialization Race

Two threads could both pass the outer null-check, both enter the constructor, and create two ElevatorSystem instances — each with their own set of elevators and completely divergent state.

✓ Fix: volatile + synchronized inner block (Java) / threading.Lock (Python) guarantees single creation.

Complexity analysis for the key operations in the system:

OperationTimeNotes
requestElevator()O(E)E = number of elevators; NearestCar scans all
selectFloor()O(log Q)Q = queue size; insert into sorted set
addRequest(floor)O(log Q)TreeSet / SortedList insert
step() — next stopO(log Q)Remove head from sorted set + update floor
costToServe(floor)O(1)Simple absolute difference
ZoneStrategy dispatchO(1)Direct array index by zone
Space: O(E × Q) total — E elevators each holding at most Q queued floor stops. In practice Q is bounded by the number of floors (at most one request per floor per direction), so space is O(E × F) where F is total floors.

Discuss these with the interviewer to show you're thinking beyond the basic design. Don't implement them all — just articulate the design approach.

Priority / VIP Floors

Add a priority field to ElevatorRequest. Use a PriorityQueue instead of SortedSet so higher-priority stops are served first within the same direction sweep.

Emergency Mode

Override all queues and dispatch every elevator to floor 1 immediately. Add an EMERGENCY state to ElevatorState and short-circuit step() logic.

Floor Display Board

Observer/Publisher: Elevator emits FloorChangedEvent on every step(). DisplayBoard subscribes and updates "Elevator 2 → Floor 7 ↑" in real-time.

Weight / Capacity Sensor

Add a currentLoad field to Elevator. DispatchStrategy skips elevators at capacity. Elevator refuses addRequest() if weight sensor reports overload.

Distributed Multi-Building

Extract ElevatorSystem to a microservice per building. A central controller aggregates availability across buildings. Use message queues (Kafka) for request routing.

Machine Learning Dispatch

Replace NearestCarStrategy with an MLDispatchStrategy that predicts peak-hour demand per floor and pre-positions elevators. Same interface, zero system changes.

15. Key Design Decisions

SCAN Algorithm (Elevator Algorithm)

Each elevator sweeps in one direction, picking up all requests along the way, then reverses. This minimises total travel distance and prevents starvation — exactly like a disk scheduler.

Strategy Pattern for Dispatch

The DispatchStrategy interface lets you swap Nearest Car, Zone-based, or ML-based dispatch without touching ElevatorSystem. New strategy = new class, zero changes elsewhere.

Two Sorted Queues per Elevator

Up-requests and down-requests are stored in separate sorted sets. This makes SCAN trivially O(log n) insert and O(1) next-stop lookup, while correctly handling direction reversal.

Tick-based simulation vs. real threading

The demo uses a tick() loop for simplicity. In production, each elevator runs in its own thread with a scheduler. The lock-based design supports both models without changes.

16. Common Follow-up Interview Questions

  • How would you handle an elevator that gets stuck mid-floor?

    💡 Add a STUCK state, a watchdog timer per elevator, and an alerting mechanism (Observer).

  • How would you implement a "group dispatch" algorithm?

    💡 NearestCar considers direction match — prefer elevators already moving towards the request floor.

  • How would you add a weight/capacity limit per elevator?

    💡 Track currentLoad in Elevator; DispatchStrategy skips elevators where load >= capacity.

  • How would you support VIP/express elevators for certain floors?

    💡 Add floorFilter: Set<Integer> per Elevator; DispatchStrategy only considers matching elevators.

  • How would you prevent elevator starvation on lower floors?

    💡 SCAN guarantees no starvation — every floor is served within one full sweep up and down.

  • How would you design the system for a 100-floor skyscraper?

    💡 ZoneStrategy + sky lobbies; elevators only serve their assigned zone, inter-zone transfers at lobby floors.

  • How would you scale to multiple buildings in a city?

    💡 Each building is an ElevatorSystem instance; a BuildingNetwork aggregates availability via REST/message queue.

  • How would you make each elevator run in its own thread?

    💡 Replace tick() loop with per-elevator Runnable/Thread; per-elevator ReentrantLock replaces system-level lock.