Front-end Engineering Lab
PatternsArchitecture Patterns

Event-Driven Architecture

Build decoupled systems that communicate through events

Event-Driven Architecture

Event-Driven Architecture (EDA) is a pattern where components communicate by emitting and listening to events, rather than directly calling each other. Used by Slack, Stripe, and Shopify.

🎯 The Concept

Traditional (Tight Coupling):
┌──────────┐      ┌──────────┐      ┌──────────┐
│ Service  │─────>│ Service  │─────>│ Service  │
│    A     │      │    B     │      │    C     │
└──────────┘      └──────────┘      └──────────┘
Each service knows about the next

Event-Driven (Loose Coupling):
┌──────────┐      ┌──────────┐      ┌──────────┐
│ Service  │      │  Event   │      │ Service  │
│    A     │─────>│   Bus    │<─────│    B     │
└──────────┘      └──────────┘      └──────────┘


                  ┌──────────┐
                  │ Service  │
                  │    C     │
                  └──────────┘
Services don't know about each other

📊 Benefits

BenefitDescription
Loose CouplingComponents don't depend on each other
ScalabilityAdd listeners without changing emitters
FlexibilityEasy to add/remove features
AsyncEvents processed asynchronously
Audit TrailAll events are logged

🔧 Basic Event Bus

Simple Implementation

type EventCallback<T = unknown> = (data: T) => void;

class EventBus {
  private events = new Map<string, EventCallback[]>();
  
  // Subscribe to event
  on<T = unknown>(event: string, callback: EventCallback<T>): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    
    this.events.get(event)!.push(callback);
    
    // Return unsubscribe function
    return () => {
      const callbacks = this.events.get(event);
      if (callbacks) {
        const index = callbacks.indexOf(callback);
        if (index > -1) {
          callbacks.splice(index, 1);
        }
      }
    };
  }
  
  // Subscribe once (auto-unsubscribe after first call)
  once<T = unknown>(event: string, callback: EventCallback<T>): void {
    const unsubscribe = this.on(event, (data) => {
      callback(data);
      unsubscribe();
    });
  }
  
  // Emit event
  emit<T = unknown>(event: string, data?: T): void {
    const callbacks = this.events.get(event);
    if (callbacks) {
      callbacks.forEach(callback => callback(data));
    }
  }
  
  // Remove all listeners for event
  off(event: string): void {
    this.events.delete(event);
  }
  
  // Remove all listeners
  clear(): void {
    this.events.clear();
  }
}

// Usage
const bus = new EventBus();

// User service emits events
function createUser(userData: User) {
  const user = saveToDatabase(userData);
  
  bus.emit('user:created', user);
  
  return user;
}

// Email service listens
bus.on('user:created', (user: User) => {
  sendWelcomeEmail(user.email);
});

// Analytics service listens
bus.on('user:created', (user: User) => {
  trackSignup(user.id);
});

// Notification service listens
bus.on('user:created', (user: User) => {
  showNotification(`Welcome ${user.name}!`);
});

🎨 Typed Event Bus

// Define event types
interface Events {
  'user:created': User;
  'user:updated': { id: string; changes: Partial<User> };
  'user:deleted': { id: string };
  'order:placed': Order;
  'payment:completed': { orderId: string; amount: number };
}

class TypedEventBus {
  private events = new Map<keyof Events, Function[]>();
  
  on<K extends keyof Events>(
    event: K,
    callback: (data: Events[K]) => void
  ): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    
    this.events.get(event)!.push(callback);
    
    return () => {
      const callbacks = this.events.get(event);
      if (callbacks) {
        const index = callbacks.indexOf(callback);
        if (index > -1) {
          callbacks.splice(index, 1);
        }
      }
    };
  }
  
  emit<K extends keyof Events>(event: K, data: Events[K]): void {
    const callbacks = this.events.get(event) as ((data: Events[K]) => void)[];
    if (callbacks) {
      callbacks.forEach(callback => callback(data));
    }
  }
}

// Usage with type safety!
const bus = new TypedEventBus();

bus.on('user:created', (user) => {
  // user is typed as User ✅
  console.log(user.name);
});

bus.emit('user:created', { 
  id: '1', 
  name: 'John',
  email: 'john@example.com'
});

🔄 Event Sourcing Pattern

// Store all events, not just current state
interface Event {
  id: string;
  type: string;
  timestamp: number;
  data: unknown;
}

class EventStore {
  private events: Event[] = [];
  
  append(type: string, data: unknown): Event {
    const event: Event = {
      id: `${Date.now()}-${Math.random()}`,
      type,
      timestamp: Date.now(),
      data
    };
    
    this.events.push(event);
    return event;
  }
  
  getEvents(filter?: { type?: string; since?: number }): Event[] {
    let events = this.events;
    
    if (filter?.type) {
      events = events.filter(e => e.type === filter.type);
    }
    
    if (filter?.since) {
      events = events.filter(e => e.timestamp >= filter.since);
    }
    
    return events;
  }
  
  replay(handler: (event: Event) => void): void {
    this.events.forEach(handler);
  }
}

// Usage - Shopping Cart with Event Sourcing
class ShoppingCart {
  private store = new EventStore();
  private items: CartItem[] = [];
  
  constructor() {
    // Replay all events to rebuild state
    this.store.replay((event) => {
      this.applyEvent(event);
    });
  }
  
  addItem(item: CartItem): void {
    this.store.append('cart:item-added', item);
    this.applyEvent({ type: 'cart:item-added', data: item } as Event);
  }
  
  removeItem(itemId: string): void {
    this.store.append('cart:item-removed', { itemId });
    this.applyEvent({ type: 'cart:item-removed', data: { itemId } } as Event);
  }
  
  private applyEvent(event: Event): void {
    switch (event.type) {
      case 'cart:item-added':
        this.items.push(event.data);
        break;
      case 'cart:item-removed':
        this.items = this.items.filter(i => i.id !== event.data.itemId);
        break;
    }
  }
  
  getItems(): CartItem[] {
    return this.items;
  }
  
  getHistory(): Event[] {
    return this.store.getEvents();
  }
}

// Benefits:
// - Full audit trail
// - Can rebuild state from events
// - Time travel debugging
// - Can replay events to different states

🎯 Domain Events Pattern

// Define domain events
abstract class DomainEvent {
  readonly occurredAt: Date = new Date();
  abstract readonly eventName: string;
}

class UserCreatedEvent extends DomainEvent {
  readonly eventName = 'user:created';
  
  constructor(
    public readonly userId: string,
    public readonly email: string,
    public readonly name: string
  ) {
    super();
  }
}

class OrderPlacedEvent extends DomainEvent {
  readonly eventName = 'order:placed';
  
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
    public readonly amount: number
  ) {
    super();
  }
}

// Event handler
interface EventHandler<T extends DomainEvent> {
  handle(event: T): Promise<void>;
}

class WelcomeEmailHandler implements EventHandler<UserCreatedEvent> {
  async handle(event: UserCreatedEvent): Promise<void> {
    console.log(`Sending welcome email to ${event.email}`);
    await sendEmail({
      to: event.email,
      subject: 'Welcome!',
      body: `Hello ${event.name}!`
    });
  }
}

class OrderConfirmationHandler implements EventHandler<OrderPlacedEvent> {
  async handle(event: OrderPlacedEvent): Promise<void> {
    console.log(`Order ${event.orderId} placed for $${event.amount}`);
    await sendOrderConfirmation(event.orderId);
  }
}

// Event dispatcher
class EventDispatcher {
  private handlers = new Map<string, EventHandler<DomainEvent>[]>();
  
  register<T extends DomainEvent>(
    eventName: string,
    handler: EventHandler<T>
  ): void {
    if (!this.handlers.has(eventName)) {
      this.handlers.set(eventName, []);
    }
    this.handlers.get(eventName)!.push(handler);
  }
  
  async dispatch(event: DomainEvent): Promise<void> {
    const handlers = this.handlers.get(event.eventName) || [];
    
    await Promise.all(
      handlers.map(handler => handler.handle(event))
    );
  }
}

// Setup
const dispatcher = new EventDispatcher();
dispatcher.register('user:created', new WelcomeEmailHandler());
dispatcher.register('order:placed', new OrderConfirmationHandler());

// Emit events
await dispatcher.dispatch(new UserCreatedEvent(
  '123',
  'john@example.com',
  'John Doe'
));

🔌 Integration with React

React Event Bus Hook

const EventBusContext = createContext<EventBus | null>(null);

function EventBusProvider({ children }: { children: React.ReactNode }) {
  const bus = useMemo(() => new EventBus(), []);
  
  return (
    <EventBusContext.Provider value={bus}>
      {children}
    </EventBusContext.Provider>
  );
}

function useEventBus() {
  const bus = useContext(EventBusContext);
  if (!bus) {
    throw new Error('useEventBus must be used within EventBusProvider');
  }
  return bus;
}

// Hook to subscribe to events
function useEvent<T>(event: string, callback: EventCallback<T>) {
  const bus = useEventBus();
  
  useEffect(() => {
    const unsubscribe = bus.on(event, callback);
    return unsubscribe;
  }, [bus, event, callback]);
}

// Hook to emit events
function useEmit() {
  const bus = useEventBus();
  
  return useCallback(<T>(event: string, data?: T) => {
    bus.emit(event, data);
  }, [bus]);
}

// Usage
function UserForm() {
  const emit = useEmit();
  
  const handleSubmit = (userData: User) => {
    emit('user:created', userData);
  };
  
  return <form onSubmit={handleSubmit}>...</form>;
}

function Notifications() {
  useEvent<User>('user:created', (user) => {
    toast.success(`Welcome ${user.name}!`);
  });
  
  return null;
}

function Analytics() {
  useEvent<User>('user:created', (user) => {
    analytics.track('signup', { userId: user.id });
  });
  
  return null;
}

⚡ Async Event Processing

class AsyncEventBus {
  private events = new Map<string, EventCallback[]>();
  private queue: Array<{ event: string; data: unknown }> = [];
  private processing = false;
  
  on(event: string, callback: EventCallback): () => void {
    if (!this.events.has(event)) {
      this.events.set(event, []);
    }
    
    this.events.get(event)!.push(callback);
    
    return () => {
      const callbacks = this.events.get(event);
      if (callbacks) {
        const index = callbacks.indexOf(callback);
        if (index > -1) {
          callbacks.splice(index, 1);
        }
      }
    };
  }
  
  emit(event: string, data?: unknown): void {
    this.queue.push({ event, data });
    this.processQueue();
  }
  
  private async processQueue(): Promise<void> {
    if (this.processing) return;
    
    this.processing = true;
    
    while (this.queue.length > 0) {
      const { event, data } = this.queue.shift()!;
      const callbacks = this.events.get(event) || [];
      
      // Process callbacks sequentially
      for (const callback of callbacks) {
        try {
          await callback(data);
        } catch (error) {
          console.error(`Error in event handler for "${event}":`, error);
        }
      }
    }
    
    this.processing = false;
  }
}

🏢 Real-World Examples

Stripe Webhooks (Event-Driven)

// Stripe emits events for everything
stripe.on('payment_intent.succeeded', (event) => {
  const paymentIntent = event.data.object;
  // Send receipt email
  // Update order status
  // Trigger fulfillment
});

stripe.on('payment_intent.payment_failed', (event) => {
  const paymentIntent = event.data.object;
  // Notify customer
  // Log failure
});

Shopify Event System

// Shopify triggers events for store actions
shopify.on('order_created', (order) => {
  // Send confirmation email
  // Update inventory
  // Notify warehouse
});

shopify.on('product_updated', (product) => {
  // Invalidate cache
  // Sync with search index
  // Update recommendations
});

📚 Key Takeaways

  1. Loose coupling - Services don't know about each other
  2. Easy to extend - Just add new listeners
  3. Audit trail - All events are logged
  4. Type safety - Use TypeScript for event types
  5. Async processing - Don't block on event handling
  6. Error handling - Catch errors in handlers
  7. Testing - Easy to test with mock event bus

When to use: Building modular systems, microservices, or when you need loose coupling between components.

On this page