Technology · ssh / indianDevelopers
LLD 05 — Design an Elevator System: Scheduling, Requests and Java Code
An elevator system is not just a lift moving between floors. The interesting design problem is deciding which car should answer a request and how each car should order its stops without mixing building-level scheduling with elevator-level movement.
Press the UP button on the seventh floor of a building with six elevators.
Which elevator should come?
The closest one?
Probably.
Except the closest elevator might already be moving down with passengers. Another car two floors farther away may already be travelling upward and can pick you up without reversing direction.
Then you enter the elevator and press 12.
That is a different kind of request entirely.
The button outside the elevator says:
Someone at floor 7 wants an elevator travelling upward.
The button inside says:
This particular elevator must eventually stop at floor 12.
Those two requests look similar on a screen, but they belong to different parts of the design.
That distinction is where I would start this LLD.
What Are We Designing?
Let's keep the first version realistic enough to be interesting without pretending we are reproducing the control software of a real skyscraper.
Our building has:
multiple elevator cars;
multiple floors;
UP and DOWN hall buttons;
floor buttons inside every elevator;
an elevator's current floor;
its current direction;
door-open, moving and idle states;
a central dispatcher that assigns hall requests;
a local stop queue inside each elevator.
We want to support two operations:
Hall request:
Floor 7 → UP
Cabin request:
Elevator 2 → Floor 12
We will leave these outside the first version:
weight sensors;
emergency mode;
fire-service operation;
maintenance mode;
destination-control elevators;
access-controlled floors;
door obstruction sensors;
VIP/service elevators;
persistence;
distributed controllers.
These are good follow-up questions, but putting all of them into version one would make the core design harder to see.
There Are Actually Two Scheduling Problems
This is the part worth slowing down for.
Suppose our building currently looks like this:
Floor 12
Floor 10 Elevator C ↑
Floor 8
Floor 7 [UP pressed]
Floor 5 Elevator B ↑
Floor 3 Elevator A ↓
Floor 0
Who should answer the request at floor 7?
There are two decisions in an elevator system.
Building-level dispatch
The system asks:
Which elevator should receive this hall request?
That decision may consider:
distance;
current direction;
whether the elevator is idle;
current workload;
capacity;
special operating modes.
Car-level scheduling
Once Elevator B has been selected, B has a different question:
In what order should I serve my stops?
Maybe it already needs to stop at:
8
10
12
and somebody inside now presses:
6
That new request should not necessarily cause an immediate reversal.
So we should not put every scheduling rule inside one giant Elevator class.
A useful boundary is:
ElevatorController
↓
chooses an ElevatorCar
ElevatorCar
↓
orders and serves its own stops

That separation gives us room to improve either algorithm later without rewriting the other one.
Start With the Small Domain Types
Direction is not the same thing as elevator state.
An elevator can be:
MOVING
while its direction is:
UP
Those are two different facts.
So model them separately:
public enum Direction {
UP,
DOWN,
IDLE
}public enum ElevatorState {
IDLE,
MOVING,
DOORS_OPEN
}A hall request needs both a floor and desired direction:
public record HallRequest(
int floor,
Direction direction
) {
public HallRequest {
if (direction == Direction.IDLE) {
throw new IllegalArgumentException(
"A hall request must be UP or DOWN"
);
}
}
}
This already tells us more than:
requestElevator(7);
because a person on floor 7 going to floor 2 and another going to floor 15 do not represent exactly the same scheduling opportunity.
What Should an Elevator Car Know?
An ElevatorCar should know things about itself:
current floor
direction
current state
requested stops
It should not decide whether it is the best elevator in the whole building.
It cannot make that decision cleanly because it does not own information about every other car.
For local stop scheduling, two ordered collections are enough for our first version:
upStops
downStops
If we are moving upward:
8
10
12
should naturally be served in ascending order.
When we later move downward:
9
6
2
should be served in descending order.
Java's TreeSet gives us both ordering and duplicate removal.
public final class ElevatorCar {
private final int id;
private final int minFloor;
private final int maxFloor;
private int currentFloor;
private Direction direction =
Direction.IDLE;
private ElevatorState state =
ElevatorState.IDLE;
private final NavigableSet<Integer> upStops =
new TreeSet<>();
private final NavigableSet<Integer> downStops =
new TreeSet<>(Comparator.reverseOrder());
public ElevatorCar(
int id,
int minFloor,
int maxFloor,
int initialFloor) {
if (minFloor > maxFloor) {
throw new IllegalArgumentException(
"Invalid floor range"
);
}
if (initialFloor < minFloor
|| initialFloor > maxFloor) {
throw new IllegalArgumentException(
"Initial floor is outside range"
);
}
this.id = id;
this.minFloor = minFloor;
this.maxFloor = maxFloor;
this.currentFloor = initialFloor;
}
public synchronized void addStop(int floor) {
validateFloor(floor);
if (floor > currentFloor) {
upStops.add(floor);
} else if (floor < currentFloor) {
downStops.add(floor);
} else {
openDoors();
}
}
public synchronized void step() {
if (state == ElevatorState.DOORS_OPEN) {
closeDoors();
return;
}
chooseDirectionIfNeeded();
if (direction == Direction.IDLE) {
state = ElevatorState.IDLE;
return;
}
state = ElevatorState.MOVING;
if (direction == Direction.UP) {
moveUpOneFloor();
} else {
moveDownOneFloor();
}
}
private void moveUpOneFloor() {
currentFloor++;
if (upStops.remove(currentFloor)) {
openDoors();
}
if (upStops.isEmpty()
&& state != ElevatorState.DOORS_OPEN) {
direction = downStops.isEmpty()
? Direction.IDLE
: Direction.DOWN;
}
}
private void moveDownOneFloor() {
currentFloor--;
if (downStops.remove(currentFloor)) {
openDoors();
}
if (downStops.isEmpty()
&& state != ElevatorState.DOORS_OPEN) {
direction = upStops.isEmpty()
? Direction.IDLE
: Direction.UP;
}
}
private void chooseDirectionIfNeeded() {
if (direction != Direction.IDLE) {
return;
}
if (upStops.isEmpty()
&& downStops.isEmpty()) {
return;
}
if (upStops.isEmpty()) {
direction = Direction.DOWN;
return;
}
if (downStops.isEmpty()) {
direction = Direction.UP;
return;
}
int nearestUp =
upStops.first() - currentFloor;
int nearestDown =
currentFloor - downStops.first();
direction = nearestUp <= nearestDown
? Direction.UP
: Direction.DOWN;
}
private void openDoors() {
state = ElevatorState.DOORS_OPEN;
}
private void closeDoors() {
if (upStops.isEmpty()
&& downStops.isEmpty()) {
direction = Direction.IDLE;
state = ElevatorState.IDLE;
return;
}
state = ElevatorState.IDLE;
}
private void validateFloor(int floor) {
if (floor < minFloor
|| floor > maxFloor) {
throw new IllegalArgumentException(
"Floor outside elevator range"
);
}
}
public synchronized int getId() {
return id;
}
public synchronized int getCurrentFloor() {
return currentFloor;
}
public synchronized Direction getDirection() {
return direction;
}
public synchronized ElevatorState getState() {
return state;
}
public synchronized int pendingStops() {
return upStops.size()
+ downStops.size();
}
}
There is nothing magical about two TreeSets.
They simply give us a useful first scheduling rule:
Continue serving stops in the current direction before reversing when practical.
That is similar in spirit to how a disk's SCAN/LOOK scheduling is often explained, although real elevator-control systems are obviously more complicated.
Hall Requests Need a Dispatcher
Now suppose floor 7 requests an elevator going up.
Which car receives it?
That behaviour is likely to change, so it makes sense to isolate it:
public interface DispatchStrategy {
ElevatorCar selectElevator(
HallRequest request,
List<ElevatorCar> elevators
);
}
For the first version, we'll use a simple score.
An idle elevator is attractive.
An elevator already moving toward the caller in the requested direction is also attractive.
A car moving away should receive a penalty.
public final class SimpleDispatchStrategy
implements DispatchStrategy {
private static final int DIRECTION_PENALTY = 1000;
@Override
public ElevatorCar selectElevator(
HallRequest request,
List<ElevatorCar> elevators) {
return elevators.stream()
.min(Comparator.comparingInt(
elevator ->
score(elevator, request)
))
.orElseThrow(() ->
new IllegalStateException(
"No elevators configured"
)
);
}
private int score(
ElevatorCar elevator,
HallRequest request) {
int current =
elevator.getCurrentFloor();
int distance =
Math.abs(
current - request.floor()
);
Direction direction =
elevator.getDirection();
if (direction == Direction.IDLE) {
return distance;
}
boolean movingTowardRequest =
direction == request.direction()
&& (
direction == Direction.UP
&& current <= request.floor()
||
direction == Direction.DOWN
&& current >= request.floor()
);
if (movingTowardRequest) {
return distance;
}
return distance
+ DIRECTION_PENALTY
+ elevator.pendingStops();
}
}
Is that how a real high-rise elevator group controller works?
No.
And that is worth saying clearly.
This is an interview scheduling policy, designed to make the responsibility boundary concrete. A production dispatcher may consider traffic patterns, passenger load, zoning, estimated arrival time, energy use, destination information and many other inputs.
The interface is valuable because those improvements do not require changing the elevator car itself.
The Controller Handles Building-Level Requests
The controller owns all elevator cars and the dispatcher.
public final class ElevatorController {
private final List<ElevatorCar> elevators;
private final DispatchStrategy dispatchStrategy;
public ElevatorController(
List<ElevatorCar> elevators,
DispatchStrategy dispatchStrategy) {
if (elevators.isEmpty()) {
throw new IllegalArgumentException(
"At least one elevator is required"
);
}
this.elevators =
List.copyOf(elevators);
this.dispatchStrategy =
Objects.requireNonNull(
dispatchStrategy
);
}
public synchronized int handleHallRequest(
HallRequest request) {
ElevatorCar selected =
dispatchStrategy.selectElevator(
request,
elevators
);
selected.addStop(
request.floor()
);
return selected.getId();
}
public void handleCarRequest(
int elevatorId,
int destinationFloor) {
ElevatorCar elevator =
findElevator(elevatorId);
elevator.addStop(
destinationFloor
);
}
public void tick() {
elevators.forEach(
ElevatorCar::step
);
}
private ElevatorCar findElevator(
int elevatorId) {
return elevators.stream()
.filter(e ->
e.getId() == elevatorId
)
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException(
"Unknown elevator"
)
);
}
}
The public flow is now fairly easy to read.
A person standing on floor 7 presses UP:
HallRequest(7, UP)
↓
ElevatorController
↓
DispatchStrategy
↓
select Elevator B
↓
Elevator B adds floor 7
Then the passenger enters Elevator B and presses floor 12:
Car request
Elevator B → 12
↓
B adds 12 to its own stop queue
Those are deliberately two different paths.

Why Not Give Every Request to the Nearest Elevator?
Because distance alone throws away useful information.
Consider:
Caller:
Floor 8 → wants UP
Elevator A:
Floor 7 → moving DOWN
Elevator B:
Floor 5 → moving UP
A is physically closer.
But A may already have passengers and stops below it.
Assigning the caller to B could be cheaper because B is already travelling in the correct direction.
This is why:
Math.abs(
elevatorFloor - requestedFloor
)
is not a complete scheduling strategy.
The deeper LLD point is not finding the world's best elevator algorithm.
It is designing the system so the algorithm has a clear home.

Direction and State Should Not Be Squeezed Into One Enum
Another design I often see is:
enum ElevatorState {
IDLE,
MOVING_UP,
MOVING_DOWN,
DOOR_OPEN
}It can work, but it combines two separate dimensions.
Suppose the doors open after an elevator reaches floor 8.
What direction was the car serving before the doors opened?
That information can matter when deciding which pending stop comes next.
Using:
state = DOORS_OPEN
direction = UPlets us represent both facts.
This is a small modelling choice, but these small choices are often what make later requirements easier.
Duplicate Floor Requests Should Not Create Duplicate Stops
Imagine three passengers enter the elevator and all press 12.
We do not want:
12
12
12inside the stop queue.
A TreeSet naturally turns those into one pending stop:
12The system still opens the door once at floor 12.
That is a nice example of choosing a data structure because it matches a domain invariant rather than because it happens to be familiar
Complete Runnable Java Version
Here is the whole core implementation in one file.
import java.util.Comparator;
import java.util.List;
import java.util.NavigableSet;
import java.util.Objects;
import java.util.TreeSet;
enum Direction {
UP,
DOWN,
IDLE
}
enum ElevatorState {
IDLE,
MOVING,
DOORS_OPEN
}
record HallRequest(
int floor,
Direction direction
) {
HallRequest {
Objects.requireNonNull(direction);
if (direction == Direction.IDLE) {
throw new IllegalArgumentException(
"Hall request must be UP or DOWN"
);
}
}
}
final class ElevatorCar {
private final int id;
private final int minFloor;
private final int maxFloor;
private int currentFloor;
private Direction direction =
Direction.IDLE;
private ElevatorState state =
ElevatorState.IDLE;
private final NavigableSet<Integer> upStops =
new TreeSet<>();
private final NavigableSet<Integer> downStops =
new TreeSet<>(
Comparator.reverseOrder()
);
ElevatorCar(
int id,
int minFloor,
int maxFloor,
int initialFloor) {
if (minFloor > maxFloor) {
throw new IllegalArgumentException(
"Invalid floor range"
);
}
if (initialFloor < minFloor
|| initialFloor > maxFloor) {
throw new IllegalArgumentException(
"Initial floor outside range"
);
}
this.id = id;
this.minFloor = minFloor;
this.maxFloor = maxFloor;
this.currentFloor = initialFloor;
}
synchronized void addStop(int floor) {
validateFloor(floor);
if (floor > currentFloor) {
upStops.add(floor);
} else if (floor < currentFloor) {
downStops.add(floor);
} else {
openDoors();
}
}
synchronized void step() {
if (state
== ElevatorState.DOORS_OPEN) {
closeDoors();
return;
}
chooseDirectionIfNeeded();
if (direction == Direction.IDLE) {
state = ElevatorState.IDLE;
return;
}
state = ElevatorState.MOVING;
if (direction == Direction.UP) {
moveUpOneFloor();
} else {
moveDownOneFloor();
}
}
private void moveUpOneFloor() {
currentFloor++;
if (upStops.remove(currentFloor)) {
openDoors();
}
if (upStops.isEmpty()
&& state
!= ElevatorState.DOORS_OPEN) {
direction =
downStops.isEmpty()
? Direction.IDLE
: Direction.DOWN;
}
}
private void moveDownOneFloor() {
currentFloor--;
if (downStops.remove(currentFloor)) {
openDoors();
}
if (downStops.isEmpty()
&& state
!= ElevatorState.DOORS_OPEN) {
direction =
upStops.isEmpty()
? Direction.IDLE
: Direction.UP;
}
}
private void chooseDirectionIfNeeded() {
if (direction != Direction.IDLE) {
return;
}
if (upStops.isEmpty()
&& downStops.isEmpty()) {
return;
}
if (upStops.isEmpty()) {
direction = Direction.DOWN;
return;
}
if (downStops.isEmpty()) {
direction = Direction.UP;
return;
}
int upDistance =
upStops.first() - currentFloor;
int downDistance =
currentFloor - downStops.first();
direction =
upDistance <= downDistance
? Direction.UP
: Direction.DOWN;
}
private void openDoors() {
state = ElevatorState.DOORS_OPEN;
}
private void closeDoors() {
if (upStops.isEmpty()
&& downStops.isEmpty()) {
state = ElevatorState.IDLE;
direction = Direction.IDLE;
return;
}
state = ElevatorState.IDLE;
}
private void validateFloor(int floor) {
if (floor < minFloor
|| floor > maxFloor) {
throw new IllegalArgumentException(
"Floor outside elevator range"
);
}
}
synchronized int getId() {
return id;
}
synchronized int getCurrentFloor() {
return currentFloor;
}
synchronized Direction getDirection() {
return direction;
}
synchronized ElevatorState getState() {
return state;
}
synchronized int pendingStops() {
return upStops.size()
+ downStops.size();
}
@Override
public synchronized String toString() {
return "Elevator " + id
+ " [floor=" + currentFloor
+ ", direction=" + direction
+ ", state=" + state
+ "]";
}
}
interface DispatchStrategy {
ElevatorCar selectElevator(
HallRequest request,
List<ElevatorCar> elevators
);
}
final class SimpleDispatchStrategy
implements DispatchStrategy {
private static final int
DIRECTION_PENALTY = 1000;
@Override
public ElevatorCar selectElevator(
HallRequest request,
List<ElevatorCar> elevators) {
return elevators.stream()
.min(
Comparator.comparingInt(
elevator ->
score(
elevator,
request
)
)
)
.orElseThrow();
}
private int score(
ElevatorCar elevator,
HallRequest request) {
int current =
elevator.getCurrentFloor();
int distance =
Math.abs(
current - request.floor()
);
Direction direction =
elevator.getDirection();
if (direction == Direction.IDLE) {
return distance;
}
boolean movingToward =
direction == request.direction()
&& (
direction == Direction.UP
&& current
<= request.floor()
||
direction == Direction.DOWN
&& current
>= request.floor()
);
if (movingToward) {
return distance;
}
return distance
+ DIRECTION_PENALTY
+ elevator.pendingStops();
}
}
final class ElevatorController {
private final List<ElevatorCar> elevators;
private final DispatchStrategy strategy;
ElevatorController(
List<ElevatorCar> elevators,
DispatchStrategy strategy) {
if (elevators.isEmpty()) {
throw new IllegalArgumentException(
"At least one elevator required"
);
}
this.elevators =
List.copyOf(elevators);
this.strategy =
Objects.requireNonNull(strategy);
}
synchronized int handleHallRequest(
HallRequest request) {
ElevatorCar selected =
strategy.selectElevator(
request,
elevators
);
selected.addStop(
request.floor()
);
return selected.getId();
}
void handleCarRequest(
int elevatorId,
int destinationFloor) {
findElevator(elevatorId)
.addStop(destinationFloor);
}
void tick() {
elevators.forEach(
ElevatorCar::step
);
}
List<ElevatorCar> elevators() {
return elevators;
}
private ElevatorCar findElevator(
int elevatorId) {
return elevators.stream()
.filter(e ->
e.getId() == elevatorId
)
.findFirst()
.orElseThrow(() ->
new IllegalArgumentException(
"Unknown elevator: "
+ elevatorId
)
);
}
}
public class Main {
public static void main(String[] args) {
ElevatorCar elevator1 =
new ElevatorCar(
1, 0, 20, 0
);
ElevatorCar elevator2 =
new ElevatorCar(
2, 0, 20, 5
);
ElevatorCar elevator3 =
new ElevatorCar(
3, 0, 20, 10
);
ElevatorController controller =
new ElevatorController(
List.of(
elevator1,
elevator2,
elevator3
),
new SimpleDispatchStrategy()
);
int selectedElevator =
controller.handleHallRequest(
new HallRequest(
7,
Direction.UP
)
);
System.out.println(
"Hall request assigned to elevator "
+ selectedElevator
);
controller.handleCarRequest(
selectedElevator,
12
);
for (int i = 0; i < 15; i++) {
controller.tick();
for (ElevatorCar elevator
: controller.elevators()) {
System.out.println(
elevator
);
}
System.out.println("----");
}
}
}
This is intentionally a simulation.
tick() represents the passage of one scheduling/movement step. In a real elevator, physical movement would be driven by hardware controllers and sensor events rather than a Java loop incrementing an integer floor.
That simplification is fine for LLD because the object boundaries are what we are trying to design.
What Happens If Two Hall Requests Arrive Together?
Suppose:
Floor 6 → UP
Floor 9 → DOWN
arrive on different threads.
At a minimum, the controller must avoid corrupting assignment state while elevator stop queues are being modified.
Our example synchronizes hall-request selection and synchronizes each car's mutable stop state.
For one JVM, that is enough to discuss the invariant.
It is not the architecture of a distributed elevator-control system.
The important invariant is simpler:
Adding requests must not corrupt or lose the ordered stop set of an elevator.
And if assignment decisions need a consistent snapshot of every elevator's workload, that controller-level scheduling boundary also needs coordination.
What About Starvation?
Our simple directional scheduling can still produce unpleasant behaviour.
Imagine requests continue appearing above an elevator:
10
11
12
13
14
15
...
while somebody has been waiting for floor 2.
If the system blindly keeps accepting upward work, the downward request could wait too long.
A more mature design might consider:
request age
maximum waiting time
fairness
current load
direction
estimated pickup time
This is exactly why dispatching lives behind:
DispatchStrategy
A future implementation could be:
NearestCarStrategy
DirectionalStrategy
LeastLoadedStrategy
FairDispatchStrategy
DestinationDispatchStrategy
without changing the public ElevatorController API.
Internal Requests and Hall Requests Should Not Be Treated as Identical
Another common shortcut is:
requestFloor(int floor)
for everything.
But these commands carry different information.
A hall request says:
floor = 7
direction = UP
elevator = unknown
A cabin request says:
elevator = 2
destination = 12
The first needs dispatch.
The second does not.
Elevator 2 has already been selected because the passenger is physically inside it.
Modelling those two requests separately avoids a lot of strange logic later.
What I Would Change If the Interviewer Asked for Destination Control
Modern high-capacity buildings sometimes ask passengers for their destination before they enter the elevator.
That changes the problem significantly.
Instead of:
Floor 7
press UP
↓
some elevator arrives
↓
press 12 inside
the request becomes closer to:
origin = 7
destination = 12
before a car is assigned.
Now the dispatcher can group passengers with similar journeys.
That is not a small field addition.
It changes what information is available during dispatch, so I would model it as a different request type and likely use a different DispatchStrategy.
That is a good example of recognising when a requirement genuinely changes the model instead of forcing it into the old one.
Common Mistakes
Putting Everything Inside Elevator
A weak design often ends up with:
Elevator
├── decide which elevator is closest
├── move
├── open door
├── schedule entire building
├── process hall buttons
├── process cabin buttons
├── detect overload
└── maintain all other elevators
An individual car should not own building-wide dispatch.
Using Only a FIFO Queue
Suppose an elevator is moving upward with stops:
8
10
12
and somebody requests floor 3.
A pure FIFO queue can easily create:
8 → 3 → 10 → 12which makes the elevator reverse repeatedly.
The local scheduler should understand direction.
Choosing Only the Physically Closest Car
Distance matters, but movement direction and current work matter too.
Treating Direction as the Entire State
UP, DOWN and IDLE do not tell us whether the door is currently open.
State and direction describe different dimensions.
Creating a Design Pattern for Every Noun
We do not need:
ElevatorFactory
FloorFactory
ButtonFactory
RequestFactory
ElevatorSingletonjust because this is an LLD interview.
The useful abstraction here is the dispatch strategy because that algorithm genuinely varies.

Conversation
Comments
Sign in to join the conversation.