TechLead
Lesson 6 of 27
5 min read
Software Architecture

Event-Driven Architecture

Master event-driven patterns including events, commands, and asynchronous communication for scalable systems

What is Event-Driven Architecture?

Event-Driven Architecture (EDA) is a software design pattern where the flow of the program is determined by events — significant changes in state. Instead of direct method calls, components communicate by producing and consuming events. This leads to loosely coupled systems that are easier to scale and evolve independently.

Events vs Commands vs Queries

  • Event: A notification that something happened — past tense, immutable (OrderPlaced, PaymentReceived)
  • Command: A request to do something — imperative, may be rejected (PlaceOrder, CancelSubscription)
  • Query: A request for information — no side effects (GetOrderStatus, ListProducts)

Event Types

Not all events are equal. Understanding the different types helps you design the right communication patterns for each scenario.

Event Classification

Type Purpose Example
Domain EventSomething meaningful happened in the domainOrderShipped, AccountSuspended
Integration EventCross-service communicationPublished to message broker
Notification EventThin event signaling something changedOrderUpdated (consumer fetches details)
Event-Carried State TransferEvent carries full state to avoid callbacksOrderPlaced with all order details

Implementing Event-Driven Architecture in TypeScript

// Event infrastructure
interface DomainEvent {
  eventId: string;
  eventType: string;
  aggregateId: string;
  occurredAt: Date;
  payload: Record;
  metadata: EventMetadata;
}

interface EventMetadata {
  correlationId: string;
  causationId: string;
  userId?: string;
}

// Event bus abstraction
interface EventBus {
  publish(event: DomainEvent): Promise;
  subscribe(eventType: string, handler: EventHandler): void;
}

type EventHandler = (event: DomainEvent) => Promise;

// In-memory event bus (for development/testing)
class InMemoryEventBus implements EventBus {
  private handlers = new Map();

  async publish(event: DomainEvent): Promise {
    const handlers = this.handlers.get(event.eventType) || [];
    const allHandlers = this.handlers.get("*") || [];
    const combined = [...handlers, ...allHandlers];

    for (const handler of combined) {
      try {
        await handler(event);
      } catch (error) {
        console.error(`Handler failed for ${event.eventType}:`, error);
        // In production: dead-letter queue, retry logic
      }
    }
  }

  subscribe(eventType: string, handler: EventHandler): void {
    const existing = this.handlers.get(eventType) || [];
    existing.push(handler);
    this.handlers.set(eventType, existing);
  }
}

// Kafka event bus (for production)
class KafkaEventBus implements EventBus {
  constructor(
    private readonly producer: Producer,
    private readonly consumer: Consumer,
    private readonly topicPrefix: string
  ) {}

  async publish(event: DomainEvent): Promise {
    await this.producer.send({
      topic: `${this.topicPrefix}.${event.eventType}`,
      messages: [{
        key: event.aggregateId,
        value: JSON.stringify(event),
        headers: {
          "event-type": event.eventType,
          "correlation-id": event.metadata.correlationId,
        },
      }],
    });
  }

  subscribe(eventType: string, handler: EventHandler): void {
    // Kafka consumer subscription setup
    this.consumer.subscribe({
      topic: `${this.topicPrefix}.${eventType}`,
    });
  }
}

Event Choreography vs Orchestration

There are two main approaches to coordinating work across services using events:

// Choreography: Each service reacts to events independently
// No central coordinator — services know what to do when they see an event

// Order Service
orderBus.publish({ eventType: "OrderPlaced", ... });

// Inventory Service listens and reacts
eventBus.subscribe("OrderPlaced", async (event) => {
  await inventoryService.reserveStock(event.payload.items);
  await eventBus.publish({ eventType: "StockReserved", ... });
});

// Payment Service listens and reacts
eventBus.subscribe("StockReserved", async (event) => {
  await paymentService.processPayment(event.payload.orderId);
  await eventBus.publish({ eventType: "PaymentProcessed", ... });
});

// Shipping Service listens and reacts
eventBus.subscribe("PaymentProcessed", async (event) => {
  await shippingService.scheduleShipment(event.payload.orderId);
});

// ---

// Orchestration: A central saga coordinator manages the flow
class OrderSagaOrchestrator {
  constructor(
    private readonly inventoryClient: InventoryClient,
    private readonly paymentClient: PaymentClient,
    private readonly shippingClient: ShippingClient
  ) {}

  async executeOrderSaga(order: Order): Promise {
    const steps: SagaStep[] = [];

    try {
      // Step 1: Reserve inventory
      const reservation = await this.inventoryClient.reserve(order.items);
      steps.push({ name: "reserve", compensate: () => this.inventoryClient.release(reservation.id) });

      // Step 2: Process payment
      const payment = await this.paymentClient.charge(order.total, order.customerId);
      steps.push({ name: "payment", compensate: () => this.paymentClient.refund(payment.id) });

      // Step 3: Schedule shipping
      await this.shippingClient.schedule(order.id, order.shippingAddress);

      return { success: true };
    } catch (error) {
      // Compensate in reverse order
      for (const step of steps.reverse()) {
        await step.compensate();
      }
      return { success: false, error: (error as Error).message };
    }
  }
}

Choreography vs Orchestration Trade-offs

  • Choreography pros: Loosely coupled, easy to add new consumers, no single point of failure
  • Choreography cons: Hard to understand the overall flow, difficult to debug, potential event cycles
  • Orchestration pros: Clear flow, easy to understand and debug, centralized error handling
  • Orchestration cons: Central coordinator can become a bottleneck, tighter coupling to the orchestrator

Event Schemas and Evolution

// Event schema versioning
interface VersionedEvent {
  eventId: string;
  eventType: string;
  version: number;
  payload: T;
  occurredAt: string;
}

// Schema registry for event evolution
class EventSchemaRegistry {
  private upcasters = new Map>();

  registerUpcaster(eventType: string, fromVersion: number, upcaster: EventUpcaster): void {
    const versions = this.upcasters.get(eventType) || new Map();
    versions.set(fromVersion, upcaster);
    this.upcasters.set(eventType, versions);
  }

  upcast(event: VersionedEvent): VersionedEvent {
    const versions = this.upcasters.get(event.eventType);
    if (!versions) return event;

    let current = event;
    while (versions.has(current.version)) {
      const upcaster = versions.get(current.version)!;
      current = upcaster(current);
    }
    return current;
  }
}

// Example: Evolving OrderPlaced event
// V1: { customerId, items, total }
// V2: { customerId, items, total, currency }  (added currency)
registry.registerUpcaster("OrderPlaced", 1, (event) => ({
  ...event,
  version: 2,
  payload: { ...event.payload, currency: "USD" }, // default for old events
}));

Best Practices for Event-Driven Architecture

  • Idempotent handlers: Events may be delivered more than once — handlers must produce the same result regardless
  • Schema evolution: Use schema registries and backward-compatible changes to evolve events safely
  • Dead-letter queues: Capture events that fail processing for later analysis and retry
  • Event ordering: Use partition keys to ensure ordering within an aggregate, but accept out-of-order across aggregates
  • Monitoring: Track event throughput, consumer lag, and processing errors as key metrics

Continue Learning