TechLead
Lesson 27 of 30
5 min read
System Design

Event-Driven Architecture

Learn event-driven architecture patterns including event sourcing, CQRS, event buses, and when to use event-driven design in distributed systems.

Event-Driven Architecture

Event-driven architecture (EDA) is a design paradigm where the flow of the program is determined by events. Instead of services calling each other directly (request-response), services produce and consume events asynchronously. This decoupling enables independent scaling, better fault isolation, and more flexible system evolution.

What is an Event?

An event is an immutable record of something that happened in the system. Unlike a command (which tells a system what to do), an event states what has already occurred.

Events vs. Commands vs. Queries

Concept Intent Direction Example
Event Notification of something that happened One-to-many (broadcast) OrderPlaced, UserRegistered
Command Request to perform an action One-to-one (directed) PlaceOrder, RegisterUser
Query Request for information One-to-one (directed) GetOrderStatus, GetUser
// Event structure
interface DomainEvent {
  eventId: string;          // Unique identifier
  eventType: string;        // e.g., "OrderPlaced"
  aggregateId: string;      // Entity this event belongs to
  aggregateType: string;    // e.g., "Order"
  timestamp: Date;
  version: number;          // For ordering within an aggregate
  payload: Record<string, unknown>;
  metadata: {
    correlationId: string;  // Track related events across services
    causationId: string;    // The event/command that caused this event
    userId?: string;        // Who triggered this
  };
}

// Example events
const orderPlacedEvent: DomainEvent = {
  eventId: "evt_abc123",
  eventType: "OrderPlaced",
  aggregateId: "order_456",
  aggregateType: "Order",
  timestamp: new Date(),
  version: 1,
  payload: {
    customerId: "cust_789",
    items: [
      { productId: "prod_001", quantity: 2, price: 29.99 },
    ],
    totalAmount: 59.98,
  },
  metadata: {
    correlationId: "corr_xyz",
    causationId: "cmd_place_order_111",
    userId: "cust_789",
  },
};

Event Sourcing

Event sourcing is a pattern where the state of an entity is derived from a sequence of events rather than stored as a snapshot. Instead of updating a row in a database, you append an event to an event store. The current state is reconstructed by replaying all events for that entity.

// Event-sourced Order aggregate
class Order {
  private id: string = "";
  private status: string = "";
  private items: OrderItem[] = [];
  private totalAmount: number = 0;

  // Rebuild state from events
  static fromEvents(events: DomainEvent[]): Order {
    const order = new Order();
    for (const event of events) {
      order.apply(event);
    }
    return order;
  }

  private apply(event: DomainEvent): void {
    switch (event.eventType) {
      case "OrderCreated":
        this.id = event.aggregateId;
        this.status = "CREATED";
        this.items = event.payload.items as OrderItem[];
        this.totalAmount = event.payload.totalAmount as number;
        break;
      case "OrderPaid":
        this.status = "PAID";
        break;
      case "OrderShipped":
        this.status = "SHIPPED";
        break;
      case "OrderCancelled":
        this.status = "CANCELLED";
        break;
      case "ItemAdded":
        this.items.push(event.payload.item as OrderItem);
        this.totalAmount += (event.payload.item as OrderItem).price;
        break;
    }
  }
}

// Event Store interface
interface EventStore {
  append(aggregateId: string, events: DomainEvent[], expectedVersion: number): Promise<void>;
  getEvents(aggregateId: string, fromVersion?: number): Promise<DomainEvent[]>;
  getEventsByType(eventType: string, fromTimestamp?: Date): Promise<DomainEvent[]>;
}

// Usage
async function getOrder(orderId: string): Promise<Order> {
  const events = await eventStore.getEvents(orderId);
  return Order.fromEvents(events);
}

Benefits of Event Sourcing

  • Complete audit trail: Every change is recorded, enabling full history reconstruction
  • Temporal queries: Answer questions like "what was the state at time T?"
  • Debugging: Replay events to reproduce any historical state
  • Flexibility: Create new read models by replaying events through new projections
  • Event replay: Fix bugs and rebuild state by replaying corrected event handlers

CQRS (Command Query Responsibility Segregation)

CQRS separates read and write operations into different models. The write model handles commands and produces events. The read model is optimized for queries and is built by consuming events.

// Write side: handles commands, emits events
class OrderCommandHandler {
  async handle(command: PlaceOrderCommand): Promise<void> {
    // Validate business rules
    const customer = await customerRepo.findById(command.customerId);
    if (!customer.isActive) throw new Error("Customer inactive");

    // Create event
    const event: DomainEvent = {
      eventId: generateId(),
      eventType: "OrderPlaced",
      aggregateId: generateOrderId(),
      aggregateType: "Order",
      timestamp: new Date(),
      version: 1,
      payload: {
        customerId: command.customerId,
        items: command.items,
        totalAmount: command.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
      },
      metadata: { correlationId: command.correlationId, causationId: command.commandId },
    };

    // Persist event
    await eventStore.append(event.aggregateId, [event], 0);

    // Publish to event bus
    await eventBus.publish(event);
  }
}

// Read side: consumes events, builds query-optimized views
class OrderReadModelProjection {
  async handleEvent(event: DomainEvent): Promise<void> {
    switch (event.eventType) {
      case "OrderPlaced":
        await readDb.orders.insert({
          orderId: event.aggregateId,
          customerId: event.payload.customerId,
          status: "PLACED",
          totalAmount: event.payload.totalAmount,
          itemCount: (event.payload.items as any[]).length,
          createdAt: event.timestamp,
        });
        break;
      case "OrderShipped":
        await readDb.orders.update(
          { orderId: event.aggregateId },
          { status: "SHIPPED", shippedAt: event.timestamp }
        );
        break;
    }
  }
}

// Query side: optimized read model
async function getCustomerOrders(customerId: string) {
  // Query the read-optimized view, not the event store
  return readDb.orders.find({ customerId }).sort({ createdAt: -1 });
}

Event Bus and Event Store

The event bus (or message broker) is the backbone of an event-driven system. It receives events from producers and delivers them to consumers.

Technology Type Best For
Apache Kafka Distributed log / event streaming High throughput, event replay, stream processing
RabbitMQ Message broker Complex routing, task queues, lower throughput needs
AWS SNS + SQS Managed pub/sub + queue Serverless, fan-out patterns, AWS-native apps
Redis Streams In-memory stream Low-latency, simpler use cases
EventStoreDB Purpose-built event store Event sourcing with built-in projections

Benefits and Challenges

Benefits Challenges
Loose coupling between services Eventual consistency (not always acceptable)
Independent scaling of producers and consumers Debugging is harder (tracing events across services)
Natural fit for async workflows Event ordering guarantees are complex
Easy to add new consumers without changing producers Schema evolution (changing event formats over time)
Better fault isolation Idempotency is required (events may be delivered more than once)

When to Use and When Not To

Use Event-Driven Architecture When:

  • Multiple services need to react to the same business event
  • You need loose coupling between bounded contexts
  • Workflows are naturally asynchronous (order processing, notifications)
  • You need a complete audit trail of all changes
  • You want to independently scale read and write workloads (CQRS)

Avoid Event-Driven Architecture When:

  • You need synchronous, strongly consistent responses (e.g., "is this username available?")
  • The system is simple enough for direct service-to-service calls
  • Your team lacks experience with distributed systems debugging
  • The domain is inherently request-response (CRUD applications)
  • Latency requirements are extremely tight and every millisecond counts

Continue Learning