Loading content...
Design patterns are most powerful when used together. In our elevator system, three patterns form a synergistic trio:
Individually, each pattern solves a specific problem. Together, they create a flexible, maintainable, and extensible system. This page demonstrates how these patterns integrate and why their combination is greater than the sum of their parts.
By the end of this page, you will be able to:
• Recognize when to apply each pattern and why • Implement the Observer pattern for event-driven communication • Integrate State, Strategy, and Observer into a unified architecture • Understand how patterns collaborate and complement each other • Apply clean architecture principles to pattern organization
We've already seen the State pattern in action for elevator movement. Let's solidify our understanding and explore its integration points.
Why State Pattern for Elevators?
The alternative to State pattern is massive conditional logic:
if (state === 'IDLE') {
if (event === 'REQUEST') {
// handle request in idle
} else if (event === 'EMERGENCY') {
// handle emergency in idle
}
} else if (state === 'MOVING_UP') {
if (event === 'FLOOR_REACHED') {
// handle floor reached while moving up
} else if (event === 'EMERGENCY') {
// handle emergency while moving
}
}
// ... this explodes with each new state or event
With N states and M events, you have N×M conditionals. Adding a new state touches M places; adding a new event touches N places. This doesn't scale.
The State pattern eliminates this by encapsulating state-specific behavior in dedicated classes.
State Pattern Structure in Our Elevator:
Our implementation has:
ElevatorContext - holds current state, delegates eventsIElevatorState - defines all event handlersIdleState, MovingUpState, MovingDownState, StoppedAtFloorState, MaintenanceStateThe State pattern exemplifies the Open/Closed Principle: the system is open for extension (add new states) but closed for modification (existing states unchanged). This is critical for safety-critical systems where changing tested code is risky.
The Strategy pattern encapsulates algorithms behind a common interface, allowing them to be selected at runtime. We use it for:
Why Strategy Pattern for Scheduling?
Elevator scheduling isn't one-size-fits-all:
The Strategy pattern lets building managers configure the right algorithm without code changes.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// Strategy Interface - defines the algorithm contractinterface SchedulingStrategy { selectElevator(request: Request, elevators: Elevator[]): Elevator | null; orderDestinations( currentFloor: number, direction: Direction, requests: Request[] ): Request[]; getNextDestination(currentFloor: Floor, queue: Request[]): Request | null;} // Concrete Strategy A: FCFSclass FCFSScheduler implements SchedulingStrategy { selectElevator(request: Request, elevators: Elevator[]): Elevator | null { // Return first available elevator return elevators.find(e => e.getState() === ElevatorState.IDLE) ?? null; } orderDestinations( currentFloor: number, direction: Direction, requests: Request[] ): Request[] { // FCFS: keep original order return requests; } getNextDestination(currentFloor: Floor, queue: Request[]): Request | null { return queue[0] ?? null; }} // Concrete Strategy B: LOOKclass LOOKScheduler implements SchedulingStrategy { selectElevator(request: Request, elevators: Elevator[]): Elevator | null { // Select elevator with best score (see previous implementation) return this.findBestElevator(request, elevators); } orderDestinations( currentFloor: number, direction: Direction, requests: Request[] ): Request[] { // LOOK: sort by direction, then reverse at furthest point const above = requests .filter(r => r.floor.getFloorNumber() > currentFloor) .sort((a, b) => a.floor.getFloorNumber() - b.floor.getFloorNumber()); const below = requests .filter(r => r.floor.getFloorNumber() < currentFloor) .sort((a, b) => b.floor.getFloorNumber() - a.floor.getFloorNumber()); if (direction === Direction.UP || direction === Direction.IDLE) { return [...above, ...below]; } return [...below, ...above]; } getNextDestination(currentFloor: Floor, queue: Request[]): Request | null { return queue[0] ?? null; } private findBestElevator(request: Request, elevators: Elevator[]): Elevator | null { // Implementation as shown in scheduling page // ... score calculation based on position and direction }} // Context uses strategy without knowing which oneclass ElevatorController { private strategy: SchedulingStrategy; constructor(strategy: SchedulingStrategy) { this.strategy = strategy; } setStrategy(strategy: SchedulingStrategy): void { this.strategy = strategy; console.log(`Strategy updated: ${strategy.constructor.name}`); } handleHallCall(floor: Floor, direction: Direction): void { const request = Request.createHallCall(floor, direction); // Delegate to strategy const elevator = this.strategy.selectElevator( request, this.getAvailableElevators() ); if (elevator) { elevator.addDestination(request); // Use strategy to reorder queue const ordered = this.strategy.orderDestinations( elevator.getCurrentFloor().getFloorNumber(), elevator.getDirection(), elevator.getDestinationQueue() ); elevator.setDestinationQueue(ordered); } }}Runtime Strategy Switching:
The power of Strategy pattern becomes clear when you can switch algorithms while the system runs. Imagine an elevator controller that adapts to traffic:
1234567891011121314151617181920212223242526272829303132333435
class AdaptiveController extends ElevatorController { private lookStrategy = new LOOKScheduler(); private cscanStrategy = new CSCANScheduler(); private currentHour: number = 0; updateStrategy(): void { const hour = new Date().getHours(); if (hour !== this.currentHour) { this.currentHour = hour; // Morning rush: LOOK for efficiency if (hour >= 7 && hour <= 10) { this.setStrategy(this.lookStrategy); console.log('Switched to LOOK for morning rush'); } // Evening rush: LOOK for efficiency else if (hour >= 16 && hour <= 19) { this.setStrategy(this.lookStrategy); console.log('Switched to LOOK for evening rush'); } // Off-peak: C-SCAN for fairness else { this.setStrategy(this.cscanStrategy); console.log('Switched to C-SCAN for fair off-peak service'); } } } // Called periodically tick(): void { this.updateStrategy(); // ... other periodic updates }}The Observer pattern establishes a one-to-many dependency between objects: when one object (subject) changes state, all its dependents (observers) are notified automatically.
Why Observer Pattern for Elevators?
Many components need to know when elevator state changes:
Without Observer, the Elevator class would need references to all these consumers, creating tight coupling. The Observer pattern decouples them completely.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// Event types for elevator systemenum ElevatorEventType { FLOOR_CHANGED = 'FLOOR_CHANGED', DIRECTION_CHANGED = 'DIRECTION_CHANGED', DOORS_OPENED = 'DOORS_OPENED', DOORS_CLOSED = 'DOORS_CLOSED', STATE_CHANGED = 'STATE_CHANGED', REQUEST_ASSIGNED = 'REQUEST_ASSIGNED', REQUEST_COMPLETED = 'REQUEST_COMPLETED', MAINTENANCE_ENTERED = 'MAINTENANCE_ENTERED', MAINTENANCE_EXITED = 'MAINTENANCE_EXITED',} // Event data structureinterface ElevatorEvent { type: ElevatorEventType; elevatorId: number; timestamp: Date; data: { previousState?: string; newState?: string; floor?: number; direction?: Direction; request?: Request; };} // Observer interfaceinterface ElevatorObserver { onElevatorEvent(event: ElevatorEvent): void;} // Subject (Observable) - mixed into Elevatorclass Observable { private observers: Set<ElevatorObserver> = new Set(); subscribe(observer: ElevatorObserver): void { this.observers.add(observer); } unsubscribe(observer: ElevatorObserver): void { this.observers.delete(observer); } protected notify(event: ElevatorEvent): void { for (const observer of this.observers) { try { observer.onElevatorEvent(event); } catch (error) { console.error('Observer error:', error); // Don't let one failing observer break others } } }} // Elevator as Subjectclass Elevator extends Observable { readonly id: number; private currentFloor: Floor; private direction: Direction = Direction.IDLE; private state: IElevatorState; constructor(id: number, initialFloor: Floor) { super(); this.id = id; this.currentFloor = initialFloor; this.state = new IdleState(); } setCurrentFloor(floor: Floor): void { const previousFloor = this.currentFloor; this.currentFloor = floor; // Notify observers of floor change this.notify({ type: ElevatorEventType.FLOOR_CHANGED, elevatorId: this.id, timestamp: new Date(), data: { floor: floor.getFloorNumber(), previousState: String(previousFloor.getFloorNumber()), newState: String(floor.getFloorNumber()), }, }); } setState(newState: IElevatorState): void { const previousState = this.state; this.state = newState; this.notify({ type: ElevatorEventType.STATE_CHANGED, elevatorId: this.id, timestamp: new Date(), data: { previousState: previousState.getName(), newState: newState.getName(), }, }); } // ... other methods also call notify() for relevant events}Concrete Observers:
Now let's implement the components that observe elevator events:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101
// Observer 1: Controller observes to manage requestsclass ControllerObserver implements ElevatorObserver { constructor(private controller: ElevatorController) {} onElevatorEvent(event: ElevatorEvent): void { switch (event.type) { case ElevatorEventType.FLOOR_CHANGED: this.controller.onElevatorArrivedAtFloor( event.elevatorId, event.data.floor! ); break; case ElevatorEventType.STATE_CHANGED: if (event.data.newState === 'IDLE') { this.controller.onElevatorBecameIdle(event.elevatorId); } break; } }} // Observer 2: Display panels for passengersclass FloorDisplayObserver implements ElevatorObserver { private displays: Map<number, FloorDisplay> = new Map(); constructor(floors: Floor[]) { for (const floor of floors) { this.displays.set(floor.getFloorNumber(), new FloorDisplay(floor)); } } onElevatorEvent(event: ElevatorEvent): void { if (event.type === ElevatorEventType.FLOOR_CHANGED) { // Update display on the floor where elevator now is const display = this.displays.get(event.data.floor!); if (display) { display.showElevatorArrival(event.elevatorId, event.data.direction!); } } if (event.type === ElevatorEventType.DIRECTION_CHANGED) { // Update all displays showing this elevator for (const display of this.displays.values()) { display.updateElevatorDirection(event.elevatorId, event.data.direction!); } } }} // Observer 3: Metrics collection for performance analysisclass MetricsObserver implements ElevatorObserver { private metrics: ElevatorMetrics = new ElevatorMetrics(); onElevatorEvent(event: ElevatorEvent): void { switch (event.type) { case ElevatorEventType.REQUEST_ASSIGNED: this.metrics.recordRequestCreated(event.data.request!); break; case ElevatorEventType.DOORS_OPENED: // Elevator arrived at a floor, serving requests this.metrics.recordElevatorArrival(event.elevatorId, event.data.floor!); break; case ElevatorEventType.REQUEST_COMPLETED: this.metrics.recordRequestCompleted(event.data.request!.id); break; } } getAverageWaitTime(): number { return this.metrics.getAverageWaitTime(); }} // Observer 4: Event logging for debugging and auditingclass LoggingObserver implements ElevatorObserver { private logger: Logger; constructor(logLevel: LogLevel = LogLevel.INFO) { this.logger = new Logger('ElevatorSystem', logLevel); } onElevatorEvent(event: ElevatorEvent): void { const message = this.formatEvent(event); switch (event.type) { case ElevatorEventType.MAINTENANCE_ENTERED: this.logger.warn(message); break; default: this.logger.info(message); } } private formatEvent(event: ElevatorEvent): string { return `[Elevator ${event.elevatorId}] ${event.type} at ${event.timestamp.toISOString()} - ${JSON.stringify(event.data)}`; }}Modern systems often use event buses or pub-sub systems (similar to Observer but with topics/channels) for more flexible routing. Libraries like RxJS extend Observer with powerful operators. The core idea remains: decouple producers from consumers.
Now let's see how State, Strategy, and Observer work together. The key is understanding their interaction points:
The Flow:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
// The complete integrated systemclass ElevatorSystem { private controller: ElevatorController; private elevators: Elevator[] = []; private floors: Floor[] = []; // Observers private displayObserver: FloorDisplayObserver; private metricsObserver: MetricsObserver; private loggingObserver: LoggingObserver; constructor(config: BuildingConfig, strategyName: string = 'LOOK') { // Initialize floors for (let i = 0; i < config.numFloors; i++) { this.floors.push(new Floor(i)); } // Initialize controller with strategy const strategy = SchedulingStrategyFactory.create(strategyName); this.controller = new ElevatorController(strategy); // Initialize observers this.displayObserver = new FloorDisplayObserver(this.floors); this.metricsObserver = new MetricsObserver(); this.loggingObserver = new LoggingObserver(); // Initialize elevators and wire up observers for (let i = 0; i < config.numElevators; i++) { const elevator = new Elevator(i, this.floors[0]); // Subscribe observers to each elevator elevator.subscribe(this.displayObserver); elevator.subscribe(this.metricsObserver); elevator.subscribe(this.loggingObserver); elevator.subscribe(new ControllerObserver(this.controller)); this.elevators.push(elevator); } // Give controller access to elevators this.controller.setElevators(this.elevators); } // Public API pressHallButton(floorNumber: number, direction: Direction): void { this.controller.handleHallCall(floorNumber, direction); } pressCabButton(elevatorId: number, floorNumber: number): void { this.controller.handleCabCall(elevatorId, floorNumber); } pressEmergency(elevatorId: number): void { const elevator = this.elevators[elevatorId]; if (elevator) { elevator.handleEmergency(); } } // Admin API changeStrategy(strategyName: string): void { const strategy = SchedulingStrategyFactory.create(strategyName); this.controller.setStrategy(strategy); } enterMaintenance(elevatorId: number): void { const elevator = this.elevators[elevatorId]; if (elevator) { elevator.enterMaintenance(); } } exitMaintenance(elevatorId: number): void { const elevator = this.elevators[elevatorId]; if (elevator) { elevator.exitMaintenance(); } } // Metrics API getAverageWaitTime(): number { return this.metricsObserver.getAverageWaitTime(); } getSystemStatus(): SystemStatus { return { elevators: this.elevators.map(e => ({ id: e.id, floor: e.getCurrentFloor().getFloorNumber(), state: e.getState().getName(), direction: e.getDirection(), queueLength: e.getDestinationQueue().length, })), averageWaitTime: this.getAverageWaitTime(), strategy: this.controller.getCurrentStrategy().constructor.name, }; }}The three patterns complement each other in specific ways that make the combined system more powerful than using any pattern alone.
Synergy 1: State Changes Trigger Observer Notifications
When the State pattern transitions the elevator to a new state, the Observer pattern ensures all interested parties learn about it. The State classes don't need to know who cares—they just notify.
123456789101112131415161718192021222324252627282930313233
// In a State class, transitions notify observersclass MovingUpState implements IElevatorState { onFloorReached(context: ElevatorContext, floor: Floor): void { if (context.shouldStopAtFloor(floor)) { context.stopMotor(); // State transition context.setState(new StoppedAtFloorState()); // Notification happens inside setState() via Observer pattern // Controller, displays, metrics all get notified automatically context.openDoors(); } }} // The setState method (inherited from Observable)setState(newState: IElevatorState): void { const previousState = this.state; this.state = newState; // Observer notification this.notify({ type: ElevatorEventType.STATE_CHANGED, elevatorId: this.id, timestamp: new Date(), data: { previousState: previousState.getName(), newState: newState.getName(), }, });}Synergy 2: Strategy Influences State Transitions
The scheduling strategy determines which floors the elevator visits, which in turn affects state transitions. A different strategy might cause the elevator to stop at different floors, leading to different state sequences.
123456789101112131415161718192021222324252627282930313233
// Strategy affects what floors trigger state changesclass ElevatorContext { private schedulingStrategy: SchedulingStrategy; shouldStopAtFloor(floor: Floor): boolean { // The strategy has ordered the queue // We check if this floor is the next destination const orderedQueue = this.destinationQueue; if (orderedQueue.length === 0) return false; // Different strategies order differently: // FCFS: order of arrival // LOOK: direction-optimized order // The state machine just checks the first item return orderedQueue[0].floor.equals(floor); } // When new request arrives, strategy reorders addDestination(request: Request): void { this.destinationQueue.push(request); // Strategy reorders based on algorithm this.destinationQueue = this.schedulingStrategy.orderDestinations( this.currentFloor.getFloorNumber(), this.direction, this.destinationQueue ); // State might need to react (if in IDLE and now have destination) this.state.onRequestReceived(this, request); }}Synergy 3: Observer Feedback to Strategy
The metrics gathered by observers can inform strategy adaptation. This creates a feedback loop where the system learns from its own performance.
| Pattern | Primary Responsibility | Integrates With | Key Benefit |
|---|---|---|---|
| State | Manages behavioral modes | Observer (notifies), Strategy (receives reordered queues) | Clean state transitions, no conditionals |
| Strategy | Encapsulates algorithms | State (affects transitions), Controller (configures) | Swappable algorithms, runtime adaptation |
| Observer | Decouples event production/consumption | State (receives notifications), Strategy (provides metrics) | Loose coupling, easy to add consumers |
With patterns in place, let's organize the code following Clean Architecture principles. This ensures the elevator system remains testable, maintainable, and independent of frameworks.
Project Structure:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
elevator-system/├── src/│ ├── domain/ # Core business logic│ │ ├── entities/│ │ │ ├── Elevator.ts│ │ │ ├── Floor.ts│ │ │ ├── Request.ts│ │ │ └── index.ts│ │ ├── value-objects/│ │ │ ├── Direction.ts│ │ │ ├── ElevatorState.ts│ │ │ ├── DoorState.ts│ │ │ └── index.ts│ │ ├── states/ # State pattern│ │ │ ├── IElevatorState.ts│ │ │ ├── IdleState.ts│ │ │ ├── MovingUpState.ts│ │ │ ├── MovingDownState.ts│ │ │ ├── StoppedAtFloorState.ts│ │ │ ├── MaintenanceState.ts│ │ │ └── index.ts│ │ └── events/│ │ ├── ElevatorEvent.ts│ │ └── ElevatorEventType.ts│ ││ ├── application/ # Use cases and orchestration│ │ ├── controllers/│ │ │ └── ElevatorController.ts│ │ ├── strategies/ # Strategy pattern│ │ │ ├── SchedulingStrategy.ts│ │ │ ├── FCFSScheduler.ts│ │ │ ├── SCANScheduler.ts│ │ │ ├── LOOKScheduler.ts│ │ │ ├── DispatchStrategy.ts│ │ │ └── StrategyFactory.ts│ │ └── observers/ # Observer pattern│ │ ├── ElevatorObserver.ts│ │ ├── ControllerObserver.ts│ │ ├── DisplayObserver.ts│ │ ├── MetricsObserver.ts│ │ └── LoggingObserver.ts│ ││ ├── infrastructure/ # External concerns│ │ ├── persistence/│ │ │ └── MetricsRepository.ts│ │ ├── display/│ │ │ └── FloorDisplayAdapter.ts│ │ └── logging/│ │ └── Logger.ts│ ││ ├── interfaces/ # Entry points│ │ ├── api/│ │ │ └── ElevatorAPI.ts│ │ └── simulation/│ │ └── SimulationRunner.ts│ ││ └── config/│ └── BuildingConfig.ts│├── tests/│ ├── unit/│ │ ├── states/│ │ ├── strategies/│ │ └── entities/│ └── integration/│ └── ElevatorSystem.test.ts│└── package.jsonWhen presenting your design, walking through this structure shows architectural thinking. Mentioning 'the domain layer has no dependencies on infrastructure, making it easy to test and port to different deployment environments' demonstrates senior-level awareness.
Pattern Integration Mastered:
We've seen how three patterns work in harmony:
What's Next:
The final page brings everything together in a Complete Design Walkthrough. We'll:
This synthesis demonstrates mastery—showing not just that you know patterns, but that you can weave them into a cohesive, production-grade design.
You now understand how senior engineers combine patterns purposefully. The key insight: patterns aren't used in isolation. They interact, reinforce each other, and together create systems that are greater than the sum of their parts.