11. Elevator System Intermediate → Advanced+Concurrency

A. Realistic Interview Prompt

"Design the control system for a bank of elevators in a building. Support external hall calls (up/down buttons on each floor) and internal car calls (floor buttons inside each cabin), and decide which elevator services which request."

B. Difficulty

Intermediate → Advanced. The state machine for a single elevator is straightforward; the real difficulty is dispatch — deciding which of several elevators answers a hall call — and doing that assignment safely when multiple hall-call events can arrive concurrently.

C. What This Problem Tests

D. Requirements

Functional

  1. Support N elevators serving M floors in one building
  2. Accept external hall calls (floor, direction) and internal car calls (destination floor from inside a cabin)
  3. Assign each hall call to exactly one elevator
  4. Move an elevator toward its pending requests, opening doors at each stop it must serve
  5. Report each elevator's current floor, direction, and door state
  6. Take an elevator out of service (maintenance/fault) and redistribute its pending requests

Non-Functional

E. Clarifying Questions

#QuestionWhy It MattersDesign Decision Affected
1How many elevators and floors, roughly?A single elevator needs only a state machine; multiple elevators require a Dispatcher and an assignment strategyWhether M (Dispatcher/Strategy) is in scope at all
2Should the dispatch algorithm be swappable (e.g. nearest-car vs. zoning for peak hours)?Determines whether dispatch logic is a Strategy interface or a single hardcoded methodPresence of DispatchStrategy
3Is there a maximum weight/capacity per cabin?Affects whether a full elevator can skip additional hall calls in its directionScope of Elevator.capacity check
4Do any floors have restricted access (e.g. a penthouse requiring a keycard)?Introduces an access-control concern layered on top of car callsExcluded by assumption unless asked (see F)
5Should the system optimize for anything beyond wait time (e.g. energy use, express floors)?Determines whether the dispatch Strategy needs multiple swappable implementations or just one reasonable defaultScope of M / AB

F. Assumptions

G. Actors

ActorResponsibility
PassengerIssues hall calls (from a floor) and car calls (from inside a cabin)
ElevatorController (facade)Top-level entry point; receives calls, delegates to Dispatcher

H. Core Domain Entities

ClassResponsibilityKey AttributesKey MethodsDoes NOT Handle
ElevatorOwns one cabin's physical state and its own pending-stop queuesid, currentFloor, direction, doorState, state, capacitystep(), addStop(floor), openDoor(), closeDoor()Deciding which elevator should serve a given hall call — that's the Dispatcher's job
ElevatorState (interface)One state's legal behavior and transitions—handleArrival(), canOpenDoor()Anything about other elevators
IdleState, MovingState, DoorsOpenState, OutOfServiceStateConcrete states———
HallCall (value object)An external requestfloor, direction requested—Knowing which elevator will answer it
CarCall (value object)An internal requestdestinationFloor——
DispatchStrategy (interface)One algorithm for choosing which elevator answers a hall call—selectElevator(call, elevators)Moving the elevator once selected
NearestCarStrategy, ScanZoneStrategyConcrete dispatch algorithms———
DispatcherAtomically assigns each incoming hall call to one elevator via the active strategyelevators, active strategydispatch(hallCall)The algorithm itself — delegated to DispatchStrategy
ElevatorController (facade)Public API; wires hall/car calls to the Dispatcher and individual elevatorsDispatcher, elevatorsrequestElevator(floor, dir), selectFloor(carId, floor), setOutOfService(carId)Dispatch algorithm details, cabin mechanics

I. Class Relationships

J. Class Diagram

current state

*

Elevator

currentFloor

direction

doorState

+step()

+addStop(floor)

«interface»

ElevatorState

+handleArrival()

+canOpenDoor()

«interface»

DispatchStrategy

+selectElevator(call, elevators)

Dispatcher

+dispatch(hallCall)

ElevatorController

+requestElevator()

+selectFloor()

IdleState

MovingState

DoorsOpenState

OutOfServiceState

NearestCarStrategy

ScanZoneStrategy

View Mermaid source
classDiagram
  class Elevator {
    currentFloor
    direction
    doorState
    +step()
    +addStop(floor)
  }
  class ElevatorState {
    <<interface>>
    +handleArrival()
    +canOpenDoor()
  }
  class DispatchStrategy {
    <<interface>>
    +selectElevator(call, elevators)
  }
  class Dispatcher {
    +dispatch(hallCall)
  }
  class ElevatorController {
    +requestElevator()
    +selectFloor()
  }
  Elevator --> ElevatorState : current state
  ElevatorState <|.. IdleState
  ElevatorState <|.. MovingState
  ElevatorState <|.. DoorsOpenState
  ElevatorState <|.. OutOfServiceState
  Dispatcher --> DispatchStrategy
  DispatchStrategy <|.. NearestCarStrategy
  DispatchStrategy <|.. ScanZoneStrategy
  ElevatorController --> Dispatcher
  Dispatcher --> "*" Elevator

K. Responsibility Mapping

ClassResponsibilityDoes NOT Handle
ElevatorOwn physical state, per-cabin stop queue, door mechanicsDeciding which call it should be assigned
ElevatorState implementationsOne state's legal transitions and behaviorAnything about other elevators or dispatch
DispatchStrategy implementationsOne algorithm for candidate elevator selectionActually claiming/locking the assignment
DispatcherAtomic claim of a hall call on the strategy's chosen elevator; reassignment on failureThe selection algorithm itself
ElevatorControllerPublic API surface, wiringCabin mechanics, dispatch algorithm

L. SOLID Analysis

PrincipleHow It's AppliedNaive Violation
SRPElevator owns cabin mechanics; Dispatcher owns assignment; DispatchStrategy owns the algorithmOne Elevator class containing its own "find the best elevator among my siblings" method
OCPA new dispatch algorithm (destination-dispatch) is a new DispatchStrategy implementation, zero changes elsewhereA growing if (mode == ZONING) ... else if (mode == NEAREST) ... block inside Dispatcher
LSPEvery ElevatorState honestly answers canOpenDoor() for its own state rather than a generic default that's wrong for movement statesA shared base state that lets doors open by default unless explicitly overridden — one forgotten override becomes a safety bug
ISPDispatchStrategy stays a single-method interface — nothing forces a strategy to also implement cabin-mechanic methods it has no business touchingA fat "ElevatorLogic" interface mixing dispatch selection with door timing constants
DIPDispatcher depends on the DispatchStrategy abstraction, not a concrete algorithmDispatcher.dispatch() calling a hardcoded nearestCar() method directly

M. Design Patterns

PatternProblem It SolvesWhere It AppearsWhen NOT to Use
StateElevator behavior and legal transitions differ sharply by mode, and illegal transitions are a real safety riskElevator / ElevatorState hierarchySkip only for a toy single-floor-shuttle with no door logic at all
StrategyThe dispatch algorithm is exactly the kind of thing an interviewer expects swapped mid-roundDispatchStrategySkip if the round explicitly fixes a single building with one elevator — then there's nothing to select among
Observer (optional)Floor displays and in-cabin panels need to react to state changes without the Elevator knowing about UI concernsOptional ElevatorObserver for displaysSkip if the round has no display/UI requirement

Explicitly avoided: Singleton for the Dispatcher — see I for why ordinary composition already gives exactly one instance without the global-access baggage Singleton would add.

N. What Is Likely to Change?

BehaviorCurrent DesignAnticipated ChangeProtecting Abstraction
Dispatch algorithmNearest-car defaultZoning for peak traffic, destination-dispatch kiosks, ML-predicted demandDispatchStrategy interface
Fleet sizeFixed N elevators known at startupElevators added/removed/serviced dynamicallyDispatcher holds a mutable collection, not a fixed array
Access controlNoneKeycard-restricted floorsA guard check ahead of accepting a car call, same Proxy shape as File System's optional permission layer

O. Design Evolution

What stays stable: ElevatorController's public API. New dispatch algorithms are pure additions; fleet size changes don't touch Elevator or state classes at all.

P. API / Interface Design

interface ElevatorState {
  void handleArrival(Elevator e);
  boolean canOpenDoor();
}

interface DispatchStrategy {
  Elevator selectElevator(HallCall call, List<Elevator> elevators);
}

class ElevatorController {
  void requestElevator(int floor, Direction dir);   // hall call
  void selectFloor(String elevatorId, int floor);   // car call
  void setOutOfService(String elevatorId);
  ElevatorStatus getStatus(String elevatorId);
}

Ownership/validation notes: requestElevator() constructs a HallCall and hands it to the Dispatcher, which selects a candidate via the active strategy and then atomically claims that call on the candidate's stop queue — if the claim fails (e.g. the candidate went out of service in the interim), it retries selection rather than dropping the call.

Q. Pseudocode — Critical Logic

Dispatcher.dispatch(hallCall):
  loop:
    candidate = strategy.selectElevator(hallCall, activeElevators)
    if candidate is null: queue call for retry; return
    lock(candidate)
    try:
      if candidate.isInService() and not candidate.isFull():
        candidate.addStop(hallCall.floor)
        return   // claimed
    finally:
      unlock(candidate)
    // candidate became unavailable between selection and claim — retry with remaining elevators

NearestCarStrategy.selectElevator(call, elevators):
  best = null; bestDistance = infinity
  for e in elevators:
    if not e.isInService(): continue
    if e.direction == call.direction or e.direction == IDLE:
      d = abs(e.currentFloor - call.floor)
      if d < bestDistance: best = e; bestDistance = d
  return best

R. End-to-End Use Case

Passenger on floor 3 presses "Up"
  │
  ▼
requestElevator(3, UP)
  │  Dispatcher asks NearestCarStrategy for a candidate among in-service elevators
  │  Elevator B (idle, floor 1) selected over Elevator A (moving down, floor 8)
  │  Dispatcher locks B, confirms still in-service, adds stop(3)
  ▼
Elevator B: IdleState → MovingState → arrives floor 3 → DoorsOpenState
  │  Passenger enters, presses floor 7 (car call)
  ▼
selectFloor("B", 7) → addStop(7) → doors close → MovingState → arrives 7 → DoorsOpenState

S. Data Structures

StructureUsed ForWhyAlternative & Trade-off
Two priority queues per elevator (up-stops min-heap, down-stops max-heap)Ordering pending stops along the current direction of travelDirect analog of the SCAN/"elevator algorithm" from OS disk scheduling — services all stops in the current direction before reversing, minimizing needless direction changesA single FIFO queue is simpler but reverses direction far more often than necessary, hurting average wait time
Map<String, Elevator> in DispatcherFleet lookup by idO(1) access for status queries and out-of-service togglingA plain list also works at small N; the map only matters once lookups by id are frequent

T. Complexity Analysis

OperationTimeSpace
selectElevator() (nearest-car)O(N) elevators scannedO(1)
addStop() / next-stop lookupO(log k) heap operations, k = pending stops for that elevatorO(k)

U. Testing Strategy

V. Interviewer Follow-Ups

Follow-UpTestingDesign ChangeClasses ChangedPattern/Trade-off
"Add zoning: dedicate elevators 1-3 to floors 1-10 during morning peak."Recognizing this as a new Strategy, not a special case in DispatcherNew ScanZoneStrategy, swapped in by time-of-dayNew class onlyValidates the Strategy investment from M
"An elevator breaks down mid-transit with passengers inside."State-machine completeness under faultsOutOfServiceState reachable from any state; pending stops redistributed by DispatcherElevator, DispatcherDirectly exercises Z
"Scale this to a 40-building campus with a shared dispatch brain."LLD/HLD boundary awarenessN/A at the LLD layer — candidate should name a fleet-management service and cross-building routing as HLD concernsN/ARecognizing the boundary, not designing it live

W. Concurrency

Shared mutable state: each elevator's stop queues and in-service flag; the Dispatcher's view of which elevators are available.

X. Persistence

Elevator and fleet state is operational, not something that needs durable storage across restarts in most rounds — a restart can reasonably reinitialize all cabins to floor 1, idle. If asked, only aggregate metrics (trip counts, average wait time) would be persisted, behind a simple repository interface, without touching Elevator or Dispatcher.

Y. Scaling Boundary

LayerWhat Belongs Here
LLDPer-elevator state machine, dispatch strategy, atomic call claiming, out-of-service redistribution
HLDMulti-building fleet management, predictive/ML-based demand dispatch, IoT sensor telemetry ingestion
Explicitly out of scopeCross-building routing and predictive dispatch — named but not designed unless the interviewer pivots there

Z. Failure Scenarios

AA. Common Bad Designs

AB. Design Trade-Offs

DecisionOption AOption BRecommendation
Dispatch algorithmNearest-car (minimize this call's wait)Zoning (minimize aggregate wait under known traffic patterns)Nearest-car as the honest simple default; name zoning explicitly as the peak-traffic follow-up rather than over-building it unprompted
Full-cabin behaviorSkip further hall calls in that directionStop anyway and let the panel refuse new car callsSkip — stopping for a call the cabin can't actually serve wastes everyone's time including the waiting passenger's

AC. Interviewer Evaluation

Strong signal: proposing State for the elevator's own behavior unprompted; separating dispatch algorithm from dispatch mechanism (Strategy vs. Dispatcher) without being led there; naming the SCAN/disk-scheduling analogy; catching the select-then-claim race before being walked into it.

Red flag: one Elevator god-class handling both cabin mechanics and fleet-wide selection; a FIFO stop queue presented as the final answer; treating "which elevator answers this" as a static rule instead of a swappable Strategy.

AD. Interview Simulation

Interviewer: Design a bank of elevators.
Candidate: Each elevator's own behavior is a State machine — idle, moving, doors-open, out-of-service — because the illegal transitions matter for safety, like never opening doors while moving.
Interviewer: Now there are five elevators. Who decides which one answers a given floor's call?
Candidate: That's a separate concern from the elevator itself — a Dispatcher using a swappable DispatchStrategy. A simple default picks the nearest elevator already heading the right direction or idle; if you need zoning for peak hours later, that's a new strategy, not a rewrite of the elevator classes.
Interviewer: What happens if two hall calls come in at the same instant and both would naturally go to the same idle elevator?
Candidate: Selecting the candidate and actually claiming the call have to be separate steps, and the claim has to be locked and re-verified — otherwise you get the same race as two people grabbing the last parking spot. Whichever claim commits first wins; the other retries selection against the now-updated fleet state.

AE. Online Solution Comparison

SourceApproachStrengthWeaknessWorth Learning
AlgoMaster.io — Design Elevator SystemState-based elevator model with a separate dispatch/selection step for multi-elevator buildingsConfirms State as the headline pattern and dispatch as a distinct concern, consistent with this document's structurePublic course outline does not go deep on the concurrent-claim race or SCAN-ordered stop queuesThe State/Dispatch separation validates this document's core structural choice
GeeksforGeeks — LLD overview and pattern catalogueGeneral LLD pattern reference (State, Strategy, Observer) applied across multiple classic problems including elevator systemsUseful as a pattern-vocabulary cross-checkNot a dedicated, fully worked elevator example with concurrency treatmentPattern terminology cross-check only

AF. Interview Cheat Sheet

ProblemElevator System
DifficultyIntermediate → Advanced+Concurrency
Core requirementsHall calls, car calls, dispatch assignment, out-of-service handling
Key clarificationsHow many cars/floors? Swappable dispatch algorithm? Capacity limits? Restricted floors?
Core entitiesElevator, ElevatorState, HallCall, CarCall, DispatchStrategy, Dispatcher, ElevatorController
Key relationshipsState drives per-cabin behavior; Strategy drives which cabin answers a call; Dispatcher is the atomicity boundary
Key patternsState (headline), Strategy (dispatch), optional Observer (displays)
Most important decisionSelect-then-atomically-claim, not select-and-assign as one step, to avoid double-assignment
Likely-changing behaviorDispatch algorithm, fleet size, access control
Concurrency concernCheck-then-act race on hall-call assignment; per-elevator (not global) locking
Top follow-upsZoning strategy, mid-transit fault recovery, multi-building LLD/HLD boundary
Common mistakesElevator class deciding fleet-wide assignment itself; FIFO instead of SCAN-ordered stops; non-atomic select-then-assign; Singleton Dispatcher
What NOT to over-engineerDon't design multi-building fleet management or ML-based predictive dispatch unless asked

AG. Full Implementation

A complete, compilable reference implementation of every class in the diagram above (composition, inheritance, interface realization, and dependency-injected strategies all preserved per section I), with a runnable demo exercising the primary flow — in Java, C++, JavaScript, and TypeScript.

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.locks.*;

/**
 * Elevator System — full class-diagram implementation.
 *
 * Elevator delegates behavior to its current ElevatorState (State pattern) so illegal
 * transitions (e.g. opening doors while moving) are structurally unrepresentable.
 * Dispatcher uses a swappable DispatchStrategy to select a candidate elevator, then
 * atomically claims the hall call under a per-elevator lock (closing the check-then-act
 * gap between "selected" and "claimed").
 */
public class p11 {

    enum Direction { UP, DOWN, IDLE }
    enum DoorState { OPEN, CLOSED }

    // ---------- Value objects ----------
    static final class HallCall {
        final int floor;
        final Direction direction;
        HallCall(int floor, Direction direction) { this.floor = floor; this.direction = direction; }
    }

    static final class CarCall {
        final int destinationFloor;
        CarCall(int destinationFloor) { this.destinationFloor = destinationFloor; }
    }

    // ---------- ElevatorState (interface, State pattern) ----------
    interface ElevatorState {
        void handleArrival(Elevator e);
        boolean canOpenDoor();
        String name();
    }

    static final class IdleState implements ElevatorState {
        @Override public void handleArrival(Elevator e) { /* nothing pending; stays idle */ }
        @Override public boolean canOpenDoor() { return true; }
        @Override public String name() { return "IDLE"; }
    }

    static final class MovingState implements ElevatorState {
        @Override public void handleArrival(Elevator e) {
            // The cabin reports arrival at a floor it must serve -> transition to DoorsOpenState.
            e.setState(new DoorsOpenState());
            e.openDoor();
        }
        @Override public boolean canOpenDoor() { return false; } // cannot open while moving
        @Override public String name() { return "MOVING"; }
    }

    static final class DoorsOpenState implements ElevatorState {
        @Override public void handleArrival(Elevator e) { /* already stopped */ }
        @Override public boolean canOpenDoor() { return true; }
        @Override public String name() { return "DOORS_OPEN"; }
    }

    static final class OutOfServiceState implements ElevatorState {
        @Override public void handleArrival(Elevator e) { /* no-op: not serving calls */ }
        @Override public boolean canOpenDoor() { return false; }
        @Override public String name() { return "OUT_OF_SERVICE"; }
    }

    // ---------- Elevator ----------
    /** Owns one cabin's physical state and its own pending-stop queue. */
    static final class Elevator {
        final String id;
        final int capacity;
        volatile int currentFloor = 0;
        volatile Direction direction = Direction.IDLE;
        volatile DoorState doorState = DoorState.CLOSED;
        private ElevatorState state = new IdleState();
        private final TreeSet<Integer> upStops = new TreeSet<>();
        private final TreeSet<Integer> downStops = new TreeSet<>();
        final ReentrantLock lock = new ReentrantLock();

        Elevator(String id, int capacity) { this.id = id; this.capacity = capacity; }

        void setState(ElevatorState state) { this.state = state; }
        ElevatorState getState() { return state; }

        boolean isInService() { return !(state instanceof OutOfServiceState); }
        boolean isFull() { return false; } // capacity tracking simplified for this demo

        /** Add a stop request; SCAN-style: keep the elevator moving in its current direction if possible. */
        void addStop(int floor) {
            if (floor > currentFloor) upStops.add(floor);
            else if (floor < currentFloor) downStops.add(floor);
            else { /* already there: open doors immediately */ }
            if (direction == Direction.IDLE && (!upStops.isEmpty() || !downStops.isEmpty())) {
                direction = !upStops.isEmpty() ? Direction.UP : Direction.DOWN;
                setState(new MovingState());
            }
        }

        /** Advance the cabin by one floor toward its next pending stop (SCAN algorithm). */
        void step() {
            if (!(state instanceof MovingState) && !(state instanceof IdleState)) return;
            if (direction == Direction.UP) {
                if (upStops.isEmpty()) {
                    direction = downStops.isEmpty() ? Direction.IDLE : Direction.DOWN;
                    if (direction == Direction.IDLE) setState(new IdleState());
                    return;
                }
                currentFloor++;
                if (upStops.contains(currentFloor)) {
                    upStops.remove(currentFloor);
                    state.handleArrival(this);
                }
            } else if (direction == Direction.DOWN) {
                if (downStops.isEmpty()) {
                    direction = upStops.isEmpty() ? Direction.IDLE : Direction.UP;
                    if (direction == Direction.IDLE) setState(new IdleState());
                    return;
                }
                currentFloor--;
                if (downStops.contains(currentFloor)) {
                    downStops.remove(currentFloor);
                    state.handleArrival(this);
                }
            }
        }

        void openDoor() {
            if (!state.canOpenDoor()) throw new IllegalStateException("Cannot open doors in state " + state.name());
            doorState = DoorState.OPEN;
        }

        void closeDoor() {
            doorState = DoorState.CLOSED;
            // Resume motion after servicing this floor, or go idle if nothing left.
            if (!upStops.isEmpty() || !downStops.isEmpty()) setState(new MovingState());
            else { direction = Direction.IDLE; setState(new IdleState()); }
        }

        void setOutOfService() { setState(new OutOfServiceState()); direction = Direction.IDLE; }

        List<Integer> pendingStops() {
            List<Integer> all = new ArrayList<>(upStops);
            all.addAll(downStops);
            return all;
        }
    }

    // ---------- DispatchStrategy (interface, Strategy pattern) ----------
    interface DispatchStrategy {
        Elevator selectElevator(HallCall call, List<Elevator> elevators);
    }

    static final class NearestCarStrategy implements DispatchStrategy {
        @Override
        public Elevator selectElevator(HallCall call, List<Elevator> elevators) {
            Elevator best = null;
            int bestDistance = Integer.MAX_VALUE;
            for (Elevator e : elevators) {
                if (!e.isInService()) continue;
                if (e.direction == call.direction || e.direction == Direction.IDLE) {
                    int d = Math.abs(e.currentFloor - call.floor);
                    if (d < bestDistance) { best = e; bestDistance = d; }
                }
            }
            return best;
        }
    }

    /** Zones the building into bands; prefers an elevator already assigned near that band. */
    static final class ScanZoneStrategy implements DispatchStrategy {
        private final int zoneSize;
        ScanZoneStrategy(int zoneSize) { this.zoneSize = zoneSize; }

        @Override
        public Elevator selectElevator(HallCall call, List<Elevator> elevators) {
            int callZone = call.floor / zoneSize;
            Elevator best = null;
            int bestScore = Integer.MAX_VALUE;
            for (Elevator e : elevators) {
                if (!e.isInService()) continue;
                int elevatorZone = e.currentFloor / zoneSize;
                int zoneDistance = Math.abs(elevatorZone - callZone) * 1000;
                int floorDistance = Math.abs(e.currentFloor - call.floor);
                int score = zoneDistance + floorDistance;
                if (score < bestScore) { best = e; bestScore = score; }
            }
            return best;
        }
    }

    // ---------- Dispatcher ----------
    /** Atomically assigns each incoming hall call to one elevator via the active strategy. */
    static final class Dispatcher {
        private final List<Elevator> elevators;
        private volatile DispatchStrategy strategy;

        Dispatcher(List<Elevator> elevators, DispatchStrategy strategy) {
            this.elevators = elevators;
            this.strategy = strategy;
        }

        void setStrategy(DispatchStrategy strategy) { this.strategy = strategy; }

        /** Select then atomically claim; retries against remaining elevators if claim fails. */
        void dispatch(HallCall call) {
            List<Elevator> remaining = new ArrayList<>(elevators);
            while (!remaining.isEmpty()) {
                Elevator candidate = strategy.selectElevator(call, remaining);
                if (candidate == null) return; // no elevator can currently serve this call
                candidate.lock.lock();
                try {
                    if (candidate.isInService() && !candidate.isFull()) {
                        candidate.addStop(call.floor);
                        return; // claimed
                    }
                } finally {
                    candidate.lock.unlock();
                }
                // candidate became unavailable between selection and claim -- retry with the rest
                remaining.remove(candidate);
            }
        }
    }

    // ---------- ElevatorController (facade) ----------
    static final class ElevatorStatus {
        final String id; final int currentFloor; final Direction direction; final DoorState doorState; final String state;
        ElevatorStatus(Elevator e) {
            this.id = e.id; this.currentFloor = e.currentFloor; this.direction = e.direction;
            this.doorState = e.doorState; this.state = e.getState().name();
        }
        @Override public String toString() {
            return String.format("Elevator[%s] floor=%d dir=%s door=%s state=%s", id, currentFloor, direction, doorState, state);
        }
    }

    static final class ElevatorController {
        private final Dispatcher dispatcher;
        private final Map<String, Elevator> elevators = new LinkedHashMap<>();

        ElevatorController(List<Elevator> elevatorList, DispatchStrategy strategy) {
            for (Elevator e : elevatorList) elevators.put(e.id, e);
            this.dispatcher = new Dispatcher(elevatorList, strategy);
        }

        void requestElevator(int floor, Direction dir) {
            dispatcher.dispatch(new HallCall(floor, dir));
        }

        void selectFloor(String elevatorId, int floor) {
            Elevator e = elevators.get(elevatorId);
            if (e == null) throw new IllegalArgumentException("No such elevator: " + elevatorId);
            e.lock.lock();
            try {
                e.addStop(floor); // car call: destination chosen from inside the cabin
            } finally {
                e.lock.unlock();
            }
        }

        void setOutOfService(String elevatorId) {
            Elevator e = elevators.get(elevatorId);
            if (e == null) return;
            e.lock.lock();
            try {
                List<Integer> pending = e.pendingStops();
                e.setOutOfService();
                // Redistribute this elevator's pending requests to the rest of the fleet.
                for (int floor : pending) {
                    dispatcher.dispatch(new HallCall(floor, Direction.IDLE));
                }
            } finally {
                e.lock.unlock();
            }
        }

        ElevatorStatus getStatus(String elevatorId) {
            Elevator e = elevators.get(elevatorId);
            if (e == null) throw new IllegalArgumentException("No such elevator: " + elevatorId);
            return new ElevatorStatus(e);
        }

        /**
         * Advance every in-service elevator by one simulated time-step. An elevator that
         * just opened its doors (previous tick's arrival) spends this tick "boarding" and
         * closes its doors, resuming motion on the following tick.
         */
        void tick() {
            for (Elevator e : elevators.values()) {
                e.lock.lock();
                try {
                    if (e.getState() instanceof DoorsOpenState) e.closeDoor();
                    else e.step();
                } finally {
                    e.lock.unlock();
                }
            }
        }
    }

    // ---------- Demo ----------
    public static void main(String[] args) {
        List<Elevator> cabins = List.of(new Elevator("E1", 8), new Elevator("E2", 8));
        ElevatorController controller = new ElevatorController(cabins, new NearestCarStrategy());

        controller.requestElevator(5, Direction.UP);
        controller.requestElevator(2, Direction.DOWN);
        controller.selectFloor("E1", 8);

        for (int i = 0; i < 12; i++) {
            controller.tick();
        }

        System.out.println(controller.getStatus("E1"));
        System.out.println(controller.getStatus("E2"));

        controller.setOutOfService("E2");
        System.out.println("After maintenance: " + controller.getStatus("E2"));
    }
}