Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Elevator LLD

Elevator System — LLD Interview Prep

A working TypeScript reference implementation of a multi-elevator system, packaged as a one-stop preparation guide for the "design an elevator system" low-level design (LLD) interview. It's meant to be read top to bottom before an interview and used as a quick reference during one.

The code is intentionally small and dependency-free so you can read all of it in under ten minutes, then spend the rest of your prep time on the reasoning — the part interviewers actually score.

Note

After exploring this repository, check out the accompanying Substack article to learn more about the design, implementation, and interview approach.

Substack

Table of contents

Quick start

npm install     # install dev dependencies (typescript, ts-node)
npm run build   # type-check and compile src/ -> dist/
npm start       # run the demo simulation (src/demo.ts)

npm start spins up an ElevatorController, fires a few hall calls, and ticks the simulation forward with controller.step() until every elevator is idle — printing the fleet's floor/direction/pending-count after every tick so you can see the dispatch and movement logic in action.

Project structure

src/
├── types/
│   ├── Direction.ts        # UP / DOWN / IDLE enum
│   └── Request.ts          # RequestType enum + the Request value object
├── core/
│   ├── Elevator.ts         # single-elevator state machine (movement, servicing)
│   └── ElevatorController.ts  # fleet owner + dispatch strategy
├── demo.ts                 # runnable simulation for manual/visual verification
└── index.ts                # public barrel export

This mirrors how you should structure the whiteboard/live-code version too: value objects and enums first, a single unit's behavior second, the orchestrator/fleet manager last. It also maps directly onto the entities you'd name in the first five minutes of the interview — see Core entities.

The problem statement

Design an elevator system for a building. Support calling an elevator to a floor, moving it, and servicing requests efficiently as elevators travel.

That's usually all you're given. Everything else in this document is about turning that one sentence into a scoped, defensible design before you write a single line of code.

Step 1 — Clarifying questions

Ask these before you draw a single box. In a real interview, the answers you get (or don't get, forcing you to state an assumption) directly shape your class diagram — asking them is itself a scored signal, not throat-clearing.

Scale & topology

  • How many floors, and how many elevators? Fixed at design time, or configurable?
  • One building or a bank shared across a lobby (e.g. low-rise/high-rise zoning)?

Request model

  • Do we need both hall calls (up/down buttons on a floor) and car calls (destination-floor buttons inside the cab)? (Spoiler: this repo only implements the former — see What a staff engineer would flag.)
  • Can a rider request multiple stops, or one destination per boarding?

Behavior & policy

  • What's the dispatch goal — minimize average wait time, minimize max wait time (fairness/no-starvation), or energy usage?
  • Do we need special modes: capacity/weight limits, express floors, fire/emergency recall, maintenance/out-of-service, VIP or accessibility priority?
  • Do doors need open/close timing and dwell time, or is arrival instantaneous for this exercise?

System qualities

  • Is this a single-process simulation (discrete time steps, one thread) or does it need to handle real concurrent requests (multiple threads/processes calling in at once)?
  • Does state need to survive a restart (persistence), or is in-memory fine?
  • Do we need to expose live position/ETA to a caller or UI?

Whatever answers you get (or don't), restate your assumptions out loud before moving on. That's what "scoping down" means in practice.

Step 2 — Scoping it down

For a 45–60 minute interview you cannot design the whole thing. Explicitly narrow to an MVP and say so — this is exactly what this repo's code commits to:

In scope (what's implemented here)

  • A fixed-size fleet of elevators serving a fixed floor range.
  • Hall calls only: PICKUP_UP / PICKUP_DOWN requests originating from a floor.
  • A greedy nearest/committed-elevator dispatch strategy.
  • Discrete-time simulation via an explicit step() tick — no real timers, no threads.
  • In-memory state only.

Explicitly out of scope (say this, then move on unless asked)

  • Car calls / destination selection once a rider has boarded.
  • Capacity, weight limits, overload detection.
  • Multiple elevator banks/zones, express floors.
  • Fire/emergency/maintenance modes.
  • Persistence, distributed coordination, real concurrency/locking.
  • Door timing, energy optimization, live ETA/UI integration.

Naming what you're deliberately not building is worth as much as naming what you are — it shows you understand the full problem space even though you're only solving a slice of it.

Functional requirements

# Requirement
F1 A rider can call an elevator to a floor, specifying a direction (up/down).
F2 The system assigns the call to a single "best" elevator.
F3 Each elevator advances one floor per simulation tick toward its current direction.
F4 An elevator services (clears) a request when it arrives at a matching floor.
F5 An elevator reverses direction once it has no more requests ahead of it in its current direction.
F6 An elevator goes idle once it has no pending requests.
F7 Duplicate hall calls for the same floor + direction are de-duplicated.
F8 Out-of-range floor requests are rejected.

Non-functional requirements

# Requirement How this repo addresses it
N1 Fairness / no starvation — every accepted request is eventually serviced. The LOOK-style sweep (reverse only at the last pending floor) guarantees every request in an elevator's set is reached; the dispatcher never drops a request — see Dispatch strategy walkthrough.
N2 Efficiency — minimize wasted travel / wait time. Approximated with a 3-tier greedy heuristic, not a globally optimal assignment — see below.
N3 Determinism / testability — the same input sequence always produces the same outcome. Explicit step() ticking with no timers, randomness, or I/O makes this trivial to unit test.
N4 Scalability — should generalize beyond 3 elevators / 10 floors. Currently hardcoded (see staff-engineer flags) but the algorithms are O(elevators × pending requests), not floor-count-dependent, so it scales fine in principle.
N5 Consistency — no two components disagree about an elevator's state. Single-threaded synchronous ticking sidesteps concurrency entirely; a real system would need this called out explicitly (see clarifying questions).

Core entities

  • Direction — UP | DOWN | IDLE. An elevator's current motion state.
  • RequestType — PICKUP_UP | PICKUP_DOWN | DESTINATION. What kind of stop is being requested: a hall call in a given direction, or (in the domain model only — see below) a car call to a destination floor.
  • Request — an immutable value object: (floor, type) plus equals(). Two requests are equal iff both fields match.
  • Elevator — a single cab's state machine: currentFloor, direction, and a Set<Request> of pending stops. Owns movement and servicing logic (step, addRequest, hasRequestsAhead, hasRequestsAtOrBeyond).
  • ElevatorController — the fleet owner / facade. Exposes requestElevator(floor, type) (the hall-call entry point) and step() (advances every elevator by one tick), and privately implements the dispatch heuristic.

Class diagram

classDiagram
    class Direction {
        <<enumeration>>
        UP
        DOWN
        IDLE
    }

    class RequestType {
        <<enumeration>>
        PICKUP_UP
        PICKUP_DOWN
        DESTINATION
    }

    class Request {
        -floor: number
        -type: RequestType
        +getFloor() number
        +getType() RequestType
        +equals(other: Request) boolean
    }

    class Elevator {
        -currentFloor: number
        -direction: Direction
        -requests: Set~Request~
        +addRequest(request: Request) boolean
        +step() void
        +hasRequestsAhead(dir: Direction) boolean
        +hasRequestsAtOrBeyond(floor: number, dir: Direction) boolean
        +getCurrentFloor() number
        +getDirection() Direction
        +getPendingCount() number
    }

    class ElevatorController {
        -elevators: Elevator[]
        +step() void
        +getElevators() Elevator[]
        +requestElevator(floor: number, type: RequestType) boolean
        -selectBestElevator(request: Request) Elevator
        -findCommittedToFloor(request: Request) Elevator
        -findNearestIdle(floor: number) Elevator
        -findNearest(floor: number) Elevator
    }

    ElevatorController "1" *-- "many" Elevator
    Elevator "1" o-- "many" Request
    Request --> RequestType
    Elevator --> Direction
Loading

Public API

Class Method Purpose
ElevatorController requestElevator(floor, type) Entry point for a hall call. Validates bounds, rejects DESTINATION, dispatches to the best elevator.
ElevatorController step() Advances every elevator in the fleet by one simulation tick.
ElevatorController getElevators() Read-only view of the fleet, for monitoring/UI/tests.
Elevator addRequest(request) Adds a stop to this elevator's set (bounds-checked, de-duplicated).
Elevator step() Advances this elevator by one tick: pick direction if idle, service the current floor, reverse or move.
Elevator getCurrentFloor() / getDirection() / getPendingCount() State getters for dispatch decisions and observability.

Demo

elevator-simulation-demo.mov

Design decisions & rationale

Discrete step() ticking instead of timers/threads. There's no setInterval, no async movement, no concurrency anywhere. The caller drives time forward explicitly. This is a deliberate interview-friendly simplification: it makes the whole system a pure function of (state, ticks), trivially unit-testable, and sidesteps a huge, separate discussion (thread-safety, locking per elevator, race conditions on shared floor-call queues) that you should name explicitly as a scoping decision rather than silently ignore.

LOOK, not SCAN. hasRequestsAhead() makes an elevator reverse direction as soon as nothing is left ahead of it — it does not keep going until it hits floor 0 or floor 9 regardless of pending work. That's the classic LOOK disk-scheduling algorithm (reverse at the last request), as opposed to SCAN (sweep all the way to the physical end every pass). Naming this correctly is a strong, cheap signal in an interview — elevator dispatch is one of the canonical real-world analogies for disk-arm scheduling algorithms.

Two pickup types instead of one PICKUP + a direction field. PICKUP_UP and PICKUP_DOWN are distinct RequestTypes rather than a single type with a direction attribute. That lets hasRequestsAtOrBeyond(floor, dir) answer "does this elevator's current sweep already plan to serve this exact hall call?" with a plain equality check, keeping Request a trivial two-field value object instead of needing a richer shape.

A 3-tier greedy heuristic instead of a global cost function. selectBestElevator doesn't score every elevator against every possible cost metric. It checks, in order: (1) an elevator already sweeping the right way that will pass this floor anyway, (2) the nearest idle elevator, (3) the nearest elevator regardless of state. This is explainable in one sentence and cheap to compute, at the cost of not being globally optimal across simultaneously-arriving requests — a good, honest trade-off to state proactively.

Set<Request> plus a hand-rolled equals(). JavaScript's Set dedupes by reference identity, not value — so two different Request instances with the same (floor, type) are not automatically merged by the Set itself. hasRequest()/removeRequest() compensate with a manual linear scan calling .equals(). Understanding why this is necessary (and its cost — see below) is a good sign you've actually read the code rather than skimmed it.

Algorithm walkthrough

Elevator.step(), traced in order:

  1. No pending requests? Set direction = IDLE and do nothing else this tick.

  2. Currently idle with pending requests? Scan all pending requests for the nearest one by absolute floor distance (ties broken toward the lower floor), and commit to UP or DOWN toward it.

    Subtlety worth raising in an interview: this direction choice is purely distance-based — it does not check whether the nearest pending request is itself a PICKUP_UP or PICKUP_DOWN call. In a rare case (the only pending call is a PICKUP_DOWN at a floor above the elevator) the elevator commits to UP, arrives, finds the pickup type doesn't match, immediately reverses, and only then services it one tick later. Correctness is preserved (the LOOK loop self-corrects), but it's a wasted tick — a good "what would you change" follow-up answer.

  3. Service the current floor. Build the two request shapes that would apply right here — a pickup matching the current direction, and a DESTINATION — and remove either from the pending set if present. If that emptied the set, go IDLE and stop; otherwise stop here for this tick either way (arriving and moving are mutually exclusive within one tick).

  4. Nothing serviced this floor. Anything left ahead in the current direction? If not, reverse direction (LOOK behavior) and stop — the reversal itself doesn't consume a movement tick.

  5. Otherwise, move one floor in the current direction.

Dispatch strategy walkthrough

ElevatorController.selectBestElevator(request), in priority order:

  1. findCommittedToFloor — is there an elevator already moving in the same direction the hall call needs, that hasn't passed the requested floor yet, and already has a request at or beyond that floor in that direction (i.e. it's guaranteed to sweep past here anyway)? If several qualify, pick the nearest. This is the "you're already going my way" fast path — no new detour needed.
  2. findNearestIdle — otherwise, hand it to the nearest elevator sitting IDLE.
  3. findNearest — otherwise, every elevator is busy going somewhere unhelpful right now; hand it to whichever is physically nearest anyway. It will pick up the request once its current sweep ends and it turns around (guaranteed by the LOOK loop, see N1 — nothing is ever dropped, just delayed).

Tip

Observable quirk (try it via npm start): if several elevators are idle at the same floor and multiple hall calls arrive before any step() is called, all of them land on the same elevator. Every tier picks the "nearest" with a strict < comparison, so ties always resolve to the first elevator in iteration order — nothing rebalances the load across the idle fleet. Good fodder for "how would you fix this?" (answer: round-robin among tied candidates, or factor pending load into the tie-break.)

Design patterns you can point to

  • Facade — ElevatorController is already one: it hides fleet management and dispatch behind two methods (requestElevator, step).
  • Strategy (extension, not present) — selectBestElevator and its three helpers are a natural DispatchStrategy interface. Extracting it would let you swap in a different heuristic (or a real cost-function optimizer) without touching ElevatorController.
  • State (extension, not present) — Direction is a plain enum with if/switch branching on it in Elevator. If an interviewer wants to see the pattern explicitly, refactor into UpState / DownState / IdleState classes behind a common ElevatorState interface, each implementing its own step()/shouldReverse().
  • Observer (extension, not present) — floor displays / a UI would subscribe to arrival and direction-change events instead of polling getCurrentFloor().
  • Value Object — Request is immutable-by-convention (no setters) with value-based equals(), which is exactly what backs the de-duplication behavior in Elevator.addRequest.

What a staff engineer would flag in this code

Reading this code closely (not just skimming it) is itself good interview prep — these are the kinds of gaps a strong candidate is expected to notice and name, even if they choose not to fix them within the time box:

  1. There is no way to add a DESTINATION request. RequestType.DESTINATION exists in the domain model and Elevator.step() knows how to clear one — but ElevatorController.requestElevator explicitly rejects DESTINATION, and nothing else ever constructs one externally. In other words: car calls (picking a floor once you've boarded) are unimplemented. This is the single biggest functional gap and a great thing to point out unprompted — then sketch the fix: an addDestination(elevatorId, floor) method on ElevatorController that forwards to Elevator.addRequest(new Request(floor, RequestType.DESTINATION)).
  2. O(n) linear scans where a Map would give O(1). hasRequest / removeRequest walk the whole Set calling .equals() because a JS Set<Request> dedupes by reference, not value. Keying a Map<string, Request> by a composite key like `${floor}:${type}` would make add/remove/has O(1) instead of O(n). Harmless at 10 floors; worth naming as a "wouldn't do this at scale" trade-off.
  3. Hardcoded bounds. The floor range (0–9) is a magic-number check duplicated in both Elevator.addRequest and ElevatorController.requestElevator, and the fleet size (3) is baked into the ElevatorController constructor. Both should be constructor parameters (or injected config) for real configurability.
  4. getElevators(): readonly Elevator[] is a shallow read-only view — the array can't be mutated, but the Elevator objects it contains still can be, since Elevator has no readonly/frozen state of its own. Fine for this scope; worth knowing the difference between "readonly array" and "immutable elements."
  5. No concurrency story at all. Every mutation happens synchronously inside a single step() call from a single caller. That's the right scope for this exercise, but say so explicitly rather than letting the interviewer assume you forgot about it.

Complexity analysis

Let E = number of elevators, n = pending requests on one elevator (bounded in practice by floors × request types).

Operation Complexity Why
Elevator.addRequest O(n) Linear hasRequest scan for de-duplication.
Elevator.step O(n) Nearest-request scan (idle branch) + ahead/removal checks, all linear in pending requests.
ElevatorController.requestElevator O(E × n) Each of the three dispatch tiers scans all E elevators, and hasRequestsAtOrBeyond is O(n) per elevator checked.
ElevatorController.step O(E × n) Ticks every elevator once.

At the scale this repo targets (3 elevators, 10 floors) all of this is effectively constant time. The complexity is still worth stating precisely — "what if we had 50 elevators and 200 floors?" is a common follow-up, and the honest answer here is "still linear in fleet size and pending requests, not in floor count, so it scales fine as written."

Edge cases to talk through out loud

  • A request for the elevator's current floor — addRequest treats this as an immediate no-op success rather than queuing it.
  • A duplicate hall call (same floor + same direction) — de-duplicated via hasRequest, second addRequest call returns false.
  • An out-of-range floor (negative, or above the top floor) — rejected at both Elevator.addRequest and ElevatorController.requestElevator.
  • Simultaneous hall calls while every elevator is idle at the same floor — all pile onto one elevator due to strict-< tie-breaking (see dispatch walkthrough).
  • A call for a floor the elevator has already passed, in its current direction — findCommittedToFloor won't match it (its floor-not-yet-passed check fails), so it routes to an idle or otherwise-nearest elevator instead of the one that just went by.
  • Two opposite-direction hall calls at the same floor (PICKUP_UP@5 and PICKUP_DOWN@5) — these are unequal Requests and coexist fine; an elevator passing floor 5 going up only clears the UP one.
  • The fleet fully drains — every elevator independently returns to IDLE the instant its own request set empties; there's no global "all done" signal (the demo's settle-loop polls each elevator's state to detect this).

Extending the design — likely follow-up questions

Common ways interviewers push on this problem once the base case works, and where you'd touch the code for each:

Follow-up Where it lands
"Add destination/car calls." Expose the addDestination method described above; Elevator.step() already handles clearing DESTINATION requests.
"Support weight/capacity limits." New field on Elevator; reject addRequest/boarding once at capacity.
"Add an express elevator serving only floors 20+." Give Elevator a floor-range/zone config instead of a shared global range; filter candidates in dispatch by zone.
"Make dispatch pluggable." Extract selectBestElevator and its helpers behind a DispatchStrategy interface (see Strategy pattern).
"What if two requests arrive at the exact same instant from different threads?" Name the concurrency gap directly; discuss a lock per elevator, a single-writer event queue, or an actor-per-elevator model.
"How do you prevent starvation under heavy load?" Point to the LOOK loop's guarantee (nothing is ever dropped, only delayed) and discuss adding a max-wait-time priority boost if true fairness bounds are required.
"Fire/emergency mode." A mode flag on ElevatorController that overrides dispatch and forces every elevator toward the ground floor, ignoring pending requests.

General LLD interview gotchas

Advice that applies beyond this specific problem:

  • Don't start coding before you've scoped. Five minutes of clarifying questions and an explicit "here's my MVP, here's what I'm deferring" saves you from solving the wrong problem for 40 minutes.
  • Name your assumptions out loud, even the ones nobody asked about (fixed floor count, single-threaded, no persistence). Silent assumptions read as "didn't think about it"; stated assumptions read as "chose to scope it out."
  • Don't force design patterns in. Mention where Strategy/State/Observer/Factory would fit and why, rather than prematurely refactoring simple enum/if-else logic into four extra classes nobody asked for. Over-engineering under time pressure is as bad a signal as under-engineering.
  • Reserve real time for working code. A beautiful class diagram with no runnable logic is a weaker interview than a slightly rougher diagram plus a step() method that actually compiles and traces correctly by hand.
  • Proactively raise non-functional requirements — fairness/starvation, efficiency, scalability — instead of waiting to be asked. This repo's own non-functional requirements section is a template for the kind of table you should be able to produce from memory.
  • When you spot a gap in your own design mid-interview, say so. Pointing out "this doesn't yet handle car calls, here's how I'd add it" is a stronger signal than hoping the interviewer doesn't notice.
  • Watch the clock. See the time-boxing suggestion below and adapt it live — if clarifying questions ate 15 minutes, cut scope further rather than rushing the code.

Suggested time-boxing for a 45–60 min interview

Phase Time Goal
Clarify & scope 5–8 min Ask the clarifying questions, state your MVP.
Entities & class diagram 5–8 min Land on the shapes in Core entities — don't over-model.
Core algorithm, live-coded 20–25 min Movement/servicing loop (Elevator.step) and dispatch (selectBestElevator) — the two pieces actually worth points.
Trade-offs & extensions 5–10 min Walk through 1–2 items from staff-engineer flags or follow-ups unprompted.
Edge cases & testing 5 min Talk through edge cases — you don't need to write every test, just show you know where the corners are.

Further reading

  • SCAN / LOOK / C-SCAN disk-scheduling algorithms — the classical CS analogy this problem is built on; understanding the elevator-arm-sweep intuition transfers directly.
  • Destination dispatch systems — how modern high-rise elevator banks actually batch and optimize assignment across simultaneous calls, well beyond this repo's greedy per-request heuristic.
  • The State design pattern — for turning Direction-based conditionals into an explicit state machine, if an interviewer wants to see that structure.

Important

Once you've explored this repository, check out the accompanying Substack blog post for a deeper dive into the design decisions, implementation details, and interview discussion.

Substack