Event Bus Pattern and Mediator vs Observer
Event Bus as a Mediator
An event bus is a popular implementation of the Mediator pattern. It provides a publish/subscribe mechanism where components communicate through events without knowing about each other.
Basic Event Bus Implementation
typescript1type EventHandler<T = any> = (data: T) => void | Promise<void>;23interface EventBusInterface {4 on<T = any>(event: string, handler: EventHandler<T>): void;5 off<T = any>(event: string, handler: EventHandler<T>): void;6 emit<T = any>(event: string, data?: T): void;7}89class EventBus implements EventBusInterface {10 private handlers = new Map<string, EventHandler[]>();1112 on<T = any>(event: string, handler: EventHandler<T>): void {13 if (!this.handlers.has(event)) {14 this.handlers.set(event, []);15 }16 this.handlers.get(event)!.push(handler);17 }1819 off<T = any>(event: string, handler: EventHandler<T>): void {20 const handlers = this.handlers.get(event);21 if (handlers) {22 const index = handlers.indexOf(handler);23 if (index > -1) {24 handlers.splice(index, 1);25 }26 }27 }2829 emit<T = any>(event: string, data?: T): void {30 const handlers = this.handlers.get(event) || [];31 handlers.forEach(handler => {32 try {33 handler(data);34 } catch (error) {35 console.error(`Error in event handler for ${event}:`, error);36 }37 });38 }3940 clear(): void {41 this.handlers.clear();42 }43}
Type-Safe Event Bus
For better type safety, we can define event types:
typescript1// Define all possible events and their data types2interface EventMap {3 'order:created': { orderId: string; customerId: string };4 'inventory:checked': { orderId: string; available: boolean };5 'inventory:reserved': { orderId: string; productId: string; quantity: number };6 'payment:processing': { orderId: string; amount: number };7 'payment:success': { orderId: string; transactionId: string };8 'payment:failed': { orderId: string; reason: string };9 'shipping:scheduled': { orderId: string; trackingNumber: string };10 'notification:sent': { orderId: string; type: string };11}1213class TypedEventBus {14 private handlers = new Map<keyof EventMap, EventHandler[]>();1516 on<K extends keyof EventMap>(17 event: K,18 handler: EventHandler<EventMap[K]>19 ): void {20 if (!this.handlers.has(event)) {21 this.handlers.set(event, []);22 }23 this.handlers.get(event)!.push(handler as EventHandler);24 }2526 off<K extends keyof EventMap>(27 event: K,28 handler: EventHandler<EventMap[K]>29 ): void {30 const handlers = this.handlers.get(event);31 if (handlers) {32 const index = handlers.indexOf(handler as EventHandler);33 if (index > -1) {34 handlers.splice(index, 1);35 }36 }37 }3839 emit<K extends keyof EventMap>(event: K, data: EventMap[K]): void {40 const handlers = this.handlers.get(event) || [];41 handlers.forEach(handler => {42 try {43 handler(data);44 } catch (error) {45 console.error(`Error in event handler for ${event}:`, error);46 }47 });48 }49}
Now TypeScript will enforce correct event names and data types:
typescript1const eventBus = new TypedEventBus();23// ✅ Correct - TypeScript knows what data to expect4eventBus.on('inventory:reserved', (data) => {5 console.log(`Reserved ${data.quantity} units of ${data.productId}`);6});78// ❌ Error - wrong event name9eventBus.on('inventory:invalid', (data) => {});1011// ❌ Error - wrong data structure12eventBus.emit('payment:success', { orderId: '123' }); // Missing transactionId
Using Event Bus for Order Processing
Let's rebuild our order processing system using an event bus:
typescript1class OrderService {2 constructor(private eventBus: TypedEventBus) {3 // Subscribe to relevant events4 this.eventBus.on('inventory:reserved', this.handleInventoryReserved.bind(this));5 this.eventBus.on('payment:success', this.handlePaymentSuccess.bind(this));6 this.eventBus.on('payment:failed', this.handlePaymentFailed.bind(this));7 }89 createOrder(order: Order): void {10 console.log(`[OrderService] Creating order ${order.id}`);1112 // Emit event to start the process13 this.eventBus.emit('order:created', {14 orderId: order.id,15 customerId: order.customerId,16 });17 }1819 private handleInventoryReserved(data: EventMap['inventory:reserved']): void {20 console.log(`[OrderService] Inventory reserved for ${data.orderId}`);21 // Could update order status in database here22 }2324 private handlePaymentSuccess(data: EventMap['payment:success']): void {25 console.log(`[OrderService] Payment successful for ${data.orderId}`);26 // Update order status27 }2829 private handlePaymentFailed(data: EventMap['payment:failed']): void {30 console.log(`[OrderService] Payment failed for ${data.orderId}: ${data.reason}`);31 // Handle failure32 }33}3435class InventoryService {36 private stock = new Map<string, number>([37 ['LAPTOP-001', 50],38 ['MOUSE-002', 200],39 ]);4041 constructor(private eventBus: TypedEventBus) {42 this.eventBus.on('order:created', this.handleOrderCreated.bind(this));43 }4445 private handleOrderCreated(data: EventMap['order:created']): void {46 // In real app, would fetch order details47 const productId = 'LAPTOP-001';48 const quantity = 1;4950 const available = (this.stock.get(productId) || 0) >= quantity;5152 if (available) {53 this.stock.set(productId, this.stock.get(productId)! - quantity);5455 this.eventBus.emit('inventory:reserved', {56 orderId: data.orderId,57 productId,58 quantity,59 });60 } else {61 // Could emit 'inventory:unavailable' event62 }63 }64}6566class PaymentService {67 constructor(private eventBus: TypedEventBus) {68 this.eventBus.on('inventory:reserved', this.handleInventoryReserved.bind(this));69 }7071 private handleInventoryReserved(data: EventMap['inventory:reserved']): void {72 // Process payment73 const success = Math.random() > 0.1;74 const amount = 999.99; // In real app, would calculate from order7576 if (success) {77 this.eventBus.emit('payment:success', {78 orderId: data.orderId,79 transactionId: `TXN-${Date.now()}`,80 });81 } else {82 this.eventBus.emit('payment:failed', {83 orderId: data.orderId,84 reason: 'Insufficient funds',85 });86 }87 }88}8990class ShippingService {91 private trackingCounter = 1000;9293 constructor(private eventBus: TypedEventBus) {94 this.eventBus.on('payment:success', this.handlePaymentSuccess.bind(this));95 }9697 private handlePaymentSuccess(data: EventMap['payment:success']): void {98 const trackingNumber = `TRK-${this.trackingCounter++}`;99100 this.eventBus.emit('shipping:scheduled', {101 orderId: data.orderId,102 trackingNumber,103 });104 }105}106107class NotificationService {108 constructor(private eventBus: TypedEventBus) {109 this.eventBus.on('payment:success', this.handlePaymentSuccess.bind(this));110 this.eventBus.on('payment:failed', this.handlePaymentFailed.bind(this));111 this.eventBus.on('shipping:scheduled', this.handleShippingScheduled.bind(this));112 }113114 private handlePaymentSuccess(data: EventMap['payment:success']): void {115 console.log(`[Notification] Sending payment success email for ${data.orderId}`);116 }117118 private handlePaymentFailed(data: EventMap['payment:failed']): void {119 console.log(`[Notification] Sending payment failed email for ${data.orderId}`);120 }121122 private handleShippingScheduled(data: EventMap['shipping:scheduled']): void {123 console.log(124 `[Notification] Sending shipping confirmation for ${data.orderId} - ${data.trackingNumber}`125 );126 }127}
Usage
typescript1const eventBus = new TypedEventBus();23// Initialize all services4const orderService = new OrderService(eventBus);5const inventoryService = new InventoryService(eventBus);6const paymentService = new PaymentService(eventBus);7const shippingService = new ShippingService(eventBus);8const notificationService = new NotificationService(eventBus);910// Create an order - the event bus coordinates everything11orderService.createOrder({12 id: 'ORD-001',13 customerId: 'CUST-123',14 productId: 'LAPTOP-001',15 quantity: 1,16});
Benefits of Event Bus Approach:
- Very loose coupling - services don't even know the mediator exists
- Easy to add new listeners without modifying existing code
- Natural fit for async operations
- Can have multiple listeners for the same event
Drawbacks:
- Workflow is harder to trace (scattered across event handlers)
- Less explicit than direct mediator methods
- Harder to debug (events firing can be opaque)
- Can lead to event spaghetti if not well organized
Mediator vs Observer Pattern
The Mediator and Observer patterns are often confused because they both involve communication between objects. Let's clarify the differences.
Observer Pattern
Purpose: Establish a one-to-many dependency where multiple observers watch a single subject
typescript1class Subject {2 private observers: Observer[] = [];34 attach(observer: Observer): void {5 this.observers.push(observer);6 }78 detach(observer: Observer): void {9 const index = this.observers.indexOf(observer);10 if (index > -1) this.observers.splice(index, 1);11 }1213 notify(): void {14 this.observers.forEach(observer => observer.update(this));15 }16}1718class ConcreteSubject extends Subject {19 private state: string = '';2021 getState(): string {22 return this.state;23 }2425 setState(state: string): void {26 this.state = state;27 this.notify(); // Automatically notify observers28 }29}3031interface Observer {32 update(subject: Subject): void;33}3435class ConcreteObserver implements Observer {36 update(subject: ConcreteSubject): void {37 console.log(`State changed to: ${subject.getState()}`);38 }39}
Key Characteristics:
- Direction: One-way (subject → observers)
- Purpose: Notify multiple objects about state changes
- Coupling: Observers know about the subject
- Control: Subject triggers notifications automatically
Mediator Pattern
Purpose: Centralize complex communications and workflows between multiple objects
typescript1interface Mediator {2 notify(sender: Component, event: string): void;3}45class ConcreteMediator implements Mediator {6 constructor(7 private component1: Component1,8 private component2: Component29 ) {}1011 notify(sender: Component, event: string): void {12 if (sender === this.component1 && event === 'A') {13 console.log('Mediator coordinates: Component1 triggered Component2');14 this.component2.doC();15 }16 }17}1819class Component {20 constructor(protected mediator: Mediator) {}21}2223class Component1 extends Component {24 doA(): void {25 this.mediator.notify(this, 'A');26 }27}
Key Characteristics:
- Direction: Bi-directional (components ↔ mediator)
- Purpose: Coordinate interactions and workflows
- Coupling: Components only know about the mediator
- Control: Mediator decides what happens next
Comparison Table
| Aspect | Observer | Mediator |
|---|---|---|
| Intent | Notification of state changes | Coordination of interactions |
| Relationship | One-to-many (1 subject, N observers) | Many-to-one-to-many (N components, 1 mediator, M components) |
| Coupling | Observers depend on subject | Components only depend on mediator |
| Communication | Unidirectional (subject → observers) | Bidirectional (component ↔ mediator ↔ component) |
| Logic Location | In subject (notify) and observers (react) | Centralized in mediator |
| Use Case | Broadcasting state changes | Orchestrating workflows |
| Example | Stock price updates to multiple displays | Air traffic control coordinating planes |
When to Use Which
Use Observer when:
- You need to notify multiple objects about state changes
- The relationship is primarily one-to-many
- Observers need to react independently to the same event
- Examples: Event listeners, data binding, pub/sub systems
Use Mediator when:
- Multiple objects need to interact in complex ways
- You want to centralize coordination logic
- You need to reduce many-to-many relationships
- The workflow involves multiple steps and decisions
- Examples: Dialog boxes, chat rooms, order processing, workflow engines
Combining Both Patterns
You can use both patterns together! The event bus is actually a combination:
typescript1class EventBasedMediator {2 private eventBus = new EventBus(); // Observer pattern34 // Mediator pattern - coordinates workflow5 processOrder(order: Order): void {6 this.eventBus.emit('order:created', order);7 }89 constructor() {10 // Set up workflow coordination11 this.eventBus.on('inventory:reserved', (data) => {12 this.eventBus.emit('payment:process', data);13 });1415 this.eventBus.on('payment:success', (data) => {16 this.eventBus.emit('shipping:schedule', data);17 });18 }19}
This gives you:
- Observer: For event notification mechanism
- Mediator: For workflow coordination logic
When to Use the Mediator Pattern
Choose the Mediator pattern when you need to:
-
Reduce Complex Dependencies
- Many objects reference each other
- Changes ripple through the system
- Testing is difficult due to tight coupling
-
Centralize Control Flow
- Workflow logic is scattered across multiple classes
- You need to understand and modify interactions in one place
- Business logic needs to be easily auditable
-
Enable Component Reusability
- Components should work independently
- Same components need to work in different contexts
- You want to swap out implementations easily
-
Manage Complex Workflows
- Multi-step processes with conditional logic
- Orchestration of multiple services
- Clear sequencing of operations
-
Implement Event-Driven Architecture
- Services communicate asynchronously
- Loose coupling is a priority
- System needs to be highly extensible
Real-World Scenarios
Good fit for Mediator:
- Order processing systems
- Workflow engines
- GUI dialog boxes
- Chat applications
- Smart home hubs
- Microservice orchestration
Better fit for Observer:
- UI event handling
- Stock price monitoring
- Notification systems
- Data synchronization
- Model-View updates
Summary
- Event Bus is a flexible implementation of the Mediator pattern
- Use typed events for better type safety and developer experience
- Observer pattern is for one-to-many notifications
- Mediator pattern is for coordinating complex interactions
- You can combine both patterns for powerful event-driven architectures
- Choose based on whether you need notification (Observer) or coordination (Mediator)
The Mediator pattern shines when you need centralized control over complex workflows, making it perfect for logistics and order processing systems.