lesson

Event Bus Pattern and Mediator vs Observer

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

typescript
1type EventHandler<T = any> = (data: T) => void | Promise<void>;
2
3interface 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}
8
9class EventBus implements EventBusInterface {
10 private handlers = new Map<string, EventHandler[]>();
11
12 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 }
18
19 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 }
28
29 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 }
39
40 clear(): void {
41 this.handlers.clear();
42 }
43}

Type-Safe Event Bus

For better type safety, we can define event types:

typescript
1// Define all possible events and their data types
2interface 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}
12
13class TypedEventBus {
14 private handlers = new Map<keyof EventMap, EventHandler[]>();
15
16 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 }
25
26 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 }
38
39 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:

typescript
1const eventBus = new TypedEventBus();
2
3// ✅ Correct - TypeScript knows what data to expect
4eventBus.on('inventory:reserved', (data) => {
5 console.log(`Reserved ${data.quantity} units of ${data.productId}`);
6});
7
8// ❌ Error - wrong event name
9eventBus.on('inventory:invalid', (data) => {});
10
11// ❌ Error - wrong data structure
12eventBus.emit('payment:success', { orderId: '123' }); // Missing transactionId

Using Event Bus for Order Processing

Let's rebuild our order processing system using an event bus:

typescript
1class OrderService {
2 constructor(private eventBus: TypedEventBus) {
3 // Subscribe to relevant events
4 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 }
8
9 createOrder(order: Order): void {
10 console.log(`[OrderService] Creating order ${order.id}`);
11
12 // Emit event to start the process
13 this.eventBus.emit('order:created', {
14 orderId: order.id,
15 customerId: order.customerId,
16 });
17 }
18
19 private handleInventoryReserved(data: EventMap['inventory:reserved']): void {
20 console.log(`[OrderService] Inventory reserved for ${data.orderId}`);
21 // Could update order status in database here
22 }
23
24 private handlePaymentSuccess(data: EventMap['payment:success']): void {
25 console.log(`[OrderService] Payment successful for ${data.orderId}`);
26 // Update order status
27 }
28
29 private handlePaymentFailed(data: EventMap['payment:failed']): void {
30 console.log(`[OrderService] Payment failed for ${data.orderId}: ${data.reason}`);
31 // Handle failure
32 }
33}
34
35class InventoryService {
36 private stock = new Map<string, number>([
37 ['LAPTOP-001', 50],
38 ['MOUSE-002', 200],
39 ]);
40
41 constructor(private eventBus: TypedEventBus) {
42 this.eventBus.on('order:created', this.handleOrderCreated.bind(this));
43 }
44
45 private handleOrderCreated(data: EventMap['order:created']): void {
46 // In real app, would fetch order details
47 const productId = 'LAPTOP-001';
48 const quantity = 1;
49
50 const available = (this.stock.get(productId) || 0) >= quantity;
51
52 if (available) {
53 this.stock.set(productId, this.stock.get(productId)! - quantity);
54
55 this.eventBus.emit('inventory:reserved', {
56 orderId: data.orderId,
57 productId,
58 quantity,
59 });
60 } else {
61 // Could emit 'inventory:unavailable' event
62 }
63 }
64}
65
66class PaymentService {
67 constructor(private eventBus: TypedEventBus) {
68 this.eventBus.on('inventory:reserved', this.handleInventoryReserved.bind(this));
69 }
70
71 private handleInventoryReserved(data: EventMap['inventory:reserved']): void {
72 // Process payment
73 const success = Math.random() > 0.1;
74 const amount = 999.99; // In real app, would calculate from order
75
76 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}
89
90class ShippingService {
91 private trackingCounter = 1000;
92
93 constructor(private eventBus: TypedEventBus) {
94 this.eventBus.on('payment:success', this.handlePaymentSuccess.bind(this));
95 }
96
97 private handlePaymentSuccess(data: EventMap['payment:success']): void {
98 const trackingNumber = `TRK-${this.trackingCounter++}`;
99
100 this.eventBus.emit('shipping:scheduled', {
101 orderId: data.orderId,
102 trackingNumber,
103 });
104 }
105}
106
107class 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 }
113
114 private handlePaymentSuccess(data: EventMap['payment:success']): void {
115 console.log(`[Notification] Sending payment success email for ${data.orderId}`);
116 }
117
118 private handlePaymentFailed(data: EventMap['payment:failed']): void {
119 console.log(`[Notification] Sending payment failed email for ${data.orderId}`);
120 }
121
122 private handleShippingScheduled(data: EventMap['shipping:scheduled']): void {
123 console.log(
124 `[Notification] Sending shipping confirmation for ${data.orderId} - ${data.trackingNumber}`
125 );
126 }
127}

Usage

typescript
1const eventBus = new TypedEventBus();
2
3// Initialize all services
4const orderService = new OrderService(eventBus);
5const inventoryService = new InventoryService(eventBus);
6const paymentService = new PaymentService(eventBus);
7const shippingService = new ShippingService(eventBus);
8const notificationService = new NotificationService(eventBus);
9
10// Create an order - the event bus coordinates everything
11orderService.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

typescript
1class Subject {
2 private observers: Observer[] = [];
3
4 attach(observer: Observer): void {
5 this.observers.push(observer);
6 }
7
8 detach(observer: Observer): void {
9 const index = this.observers.indexOf(observer);
10 if (index > -1) this.observers.splice(index, 1);
11 }
12
13 notify(): void {
14 this.observers.forEach(observer => observer.update(this));
15 }
16}
17
18class ConcreteSubject extends Subject {
19 private state: string = '';
20
21 getState(): string {
22 return this.state;
23 }
24
25 setState(state: string): void {
26 this.state = state;
27 this.notify(); // Automatically notify observers
28 }
29}
30
31interface Observer {
32 update(subject: Subject): void;
33}
34
35class 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

typescript
1interface Mediator {
2 notify(sender: Component, event: string): void;
3}
4
5class ConcreteMediator implements Mediator {
6 constructor(
7 private component1: Component1,
8 private component2: Component2
9 ) {}
10
11 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}
18
19class Component {
20 constructor(protected mediator: Mediator) {}
21}
22
23class 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

AspectObserverMediator
IntentNotification of state changesCoordination of interactions
RelationshipOne-to-many (1 subject, N observers)Many-to-one-to-many (N components, 1 mediator, M components)
CouplingObservers depend on subjectComponents only depend on mediator
CommunicationUnidirectional (subject → observers)Bidirectional (component ↔ mediator ↔ component)
Logic LocationIn subject (notify) and observers (react)Centralized in mediator
Use CaseBroadcasting state changesOrchestrating workflows
ExampleStock price updates to multiple displaysAir 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:

typescript
1class EventBasedMediator {
2 private eventBus = new EventBus(); // Observer pattern
3
4 // Mediator pattern - coordinates workflow
5 processOrder(order: Order): void {
6 this.eventBus.emit('order:created', order);
7 }
8
9 constructor() {
10 // Set up workflow coordination
11 this.eventBus.on('inventory:reserved', (data) => {
12 this.eventBus.emit('payment:process', data);
13 });
14
15 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:

  1. Reduce Complex Dependencies

    • Many objects reference each other
    • Changes ripple through the system
    • Testing is difficult due to tight coupling
  2. 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
  3. Enable Component Reusability

    • Components should work independently
    • Same components need to work in different contexts
    • You want to swap out implementations easily
  4. Manage Complex Workflows

    • Multi-step processes with conditional logic
    • Orchestration of multiple services
    • Clear sequencing of operations
  5. 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.