Files
flyer-crawler.projectium.com/docs/adr/0036-event-bus-and-pub-sub-pattern.md

6.0 KiB

ADR-036: Event Bus and Pub/Sub Pattern

Date: 2026-01-09

Status: Accepted

Implemented: 2026-01-09

Context

Modern web applications often need to handle cross-component communication without creating tight coupling between modules. In our application, several scenarios require broadcasting events across the system:

  1. Session Expiry: When a user's session expires, multiple components need to respond (auth state, UI notifications, API client).
  2. Real-time Updates: When data changes on the server, multiple UI components may need to update.
  3. Cross-Component Communication: Independent components need to communicate without direct references to each other.

Traditional approaches like prop drilling or global state management can lead to tightly coupled code that is difficult to maintain and test.

Decision

We will implement a lightweight, in-memory event bus pattern using a publish/subscribe (pub/sub) architecture. This provides:

  1. Decoupled Communication: Publishers and subscribers don't need to know about each other.
  2. Event-Driven Architecture: Components react to events rather than polling for changes.
  3. Testability: Events can be easily mocked and verified in tests.

Design Principles

  • Singleton Pattern: A single event bus instance is shared across the application.
  • Type-Safe Events: Event names are string constants to prevent typos.
  • Memory Management: Subscribers must unsubscribe when components unmount to prevent memory leaks.

Implementation Details

EventBus Class

Located in src/services/eventBus.ts:

type EventCallback = (data?: any) => void;

export class EventBus {
  private listeners: { [key: string]: EventCallback[] } = {};

  on(event: string, callback: EventCallback): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event].push(callback);
  }

  off(event: string, callback: EventCallback): void {
    if (!this.listeners[event]) return;
    this.listeners[event] = this.listeners[event].filter((l) => l !== callback);
  }

  dispatch(event: string, data?: any): void {
    if (!this.listeners[event]) return;
    this.listeners[event].forEach((callback) => callback(data));
  }
}

// Singleton instance
export const eventBus = new EventBus();

Event Constants

Define event names as constants to prevent typos:

// src/constants/events.ts
export const EVENTS = {
  SESSION_EXPIRED: 'session:expired',
  SESSION_REFRESHED: 'session:refreshed',
  USER_LOGGED_OUT: 'user:loggedOut',
  DATA_UPDATED: 'data:updated',
  NOTIFICATION_RECEIVED: 'notification:received',
} as const;

React Hook for Event Subscription

// src/hooks/useEventBus.ts
import { useEffect } from 'react';
import { eventBus } from '../services/eventBus';

export function useEventBus(event: string, callback: (data?: any) => void) {
  useEffect(() => {
    eventBus.on(event, callback);

    // Cleanup on unmount
    return () => {
      eventBus.off(event, callback);
    };
  }, [event, callback]);
}

Usage Examples

Publishing Events:

import { eventBus } from '../services/eventBus';
import { EVENTS } from '../constants/events';

// In API client when session expires
function handleSessionExpiry() {
  eventBus.dispatch(EVENTS.SESSION_EXPIRED, { reason: 'token_expired' });
}

Subscribing in Components:

import { useCallback } from 'react';
import { useEventBus } from '../hooks/useEventBus';
import { EVENTS } from '../constants/events';

function AuthenticatedComponent() {
  const handleSessionExpired = useCallback((data) => {
    console.log('Session expired:', data.reason);
    // Redirect to login, show notification, etc.
  }, []);

  useEventBus(EVENTS.SESSION_EXPIRED, handleSessionExpired);

  return <div>Protected Content</div>;
}

Subscribing in Non-React Code:

import { eventBus } from '../services/eventBus';
import { EVENTS } from '../constants/events';

// In API client
const handleLogout = () => {
  clearAuthToken();
};

eventBus.on(EVENTS.USER_LOGGED_OUT, handleLogout);

Testing

The EventBus is fully tested in src/services/eventBus.test.ts:

import { EventBus } from './eventBus';

describe('EventBus', () => {
  let bus: EventBus;

  beforeEach(() => {
    bus = new EventBus();
  });

  it('should call registered listeners when event is dispatched', () => {
    const callback = vi.fn();
    bus.on('test', callback);
    bus.dispatch('test', { value: 42 });
    expect(callback).toHaveBeenCalledWith({ value: 42 });
  });

  it('should unsubscribe listeners correctly', () => {
    const callback = vi.fn();
    bus.on('test', callback);
    bus.off('test', callback);
    bus.dispatch('test');
    expect(callback).not.toHaveBeenCalled();
  });

  it('should handle multiple listeners for the same event', () => {
    const callback1 = vi.fn();
    const callback2 = vi.fn();
    bus.on('test', callback1);
    bus.on('test', callback2);
    bus.dispatch('test');
    expect(callback1).toHaveBeenCalled();
    expect(callback2).toHaveBeenCalled();
  });
});

Consequences

Positive

  • Loose Coupling: Components don't need direct references to communicate.
  • Flexibility: New subscribers can be added without modifying publishers.
  • Testability: Easy to mock events and verify interactions.
  • Simplicity: Minimal code footprint compared to full state management solutions.

Negative

  • Debugging Complexity: Event-driven flows can be harder to trace than direct function calls.
  • Memory Leaks: Forgetting to unsubscribe can cause memory leaks (mitigated by the React hook).
  • No Type Safety for Payloads: Event data is typed as any (could be improved with generics).

Key Files

  • src/services/eventBus.ts - EventBus implementation
  • src/services/eventBus.test.ts - EventBus tests
  • ADR-005 - State Management Strategy
  • ADR-022 - Real-time Notification System