213 lines
6.0 KiB
Markdown
213 lines
6.0 KiB
Markdown
# 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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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**:
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```typescript
|
|
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**:
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```typescript
|
|
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
|
|
|
|
## Related ADRs
|
|
|
|
- [ADR-005](./0005-frontend-state-management-and-server-cache-strategy.md) - State Management Strategy
|
|
- [ADR-022](./0022-real-time-notification-system.md) - Real-time Notification System
|