Event-Driven Architecture

Difficulty: Advanced

Question

What is event-driven architecture? Explain event sourcing, CQRS, and the saga pattern.

Answer

Event-Driven Architecture (EDA) is a design pattern where components communicate through events rather than direct calls.

Core concepts: 1. Domain Events: Record what happened (OrderPlaced, PaymentReceived, ItemShipped) 2. Event Bus: Distributes events to interested subscribers (Kafka, RabbitMQ, EventBridge) 3. Event Sourcing: Store all changes as a sequence of events instead of current state. Rebuild state by replaying events. 4. CQRS (Command Query Responsibility Segregation): Separate write model (commands) from read model (queries) for independent optimization. 5. Saga Pattern: Coordinate distributed transactions across services using a sequence of events and compensating actions.

Benefits: Loose coupling, auditability, temporal queries (what was the state at time X?), scalability (independent read/write scaling).

Code examples

Event Sourcing

// Instead of storing current state:
// { balance: 150 }

// Store the sequence of events:
const events = [
  { type: 'ACCOUNT_CREATED', data: { accountId: 'acc_1' }, timestamp: '2025-01-01' },
  { type: 'MONEY_DEPOSITED', data: { amount: 200 }, timestamp: '2025-01-02' },
  { type: 'MONEY_WITHDRAWN', data: { amount: 50 }, timestamp: '2025-01-03' }
];
// Replay: 0 + 200 - 50 = 150

// Event store
class EventStore {
  async append(streamId, event) {
    await db.events.create({
      streamId,
      type: event.type,
      data: event.data,
      version: await this.getNextVersion(streamId),
      timestamp: new Date()
    });
    // Publish to event bus for subscribers
    await eventBus.publish(event.type, { streamId, ...event });
  }

  async getEvents(streamId, fromVersion = 0) {
    return db.events.findMany({
      where: { streamId, version: { gte: fromVersion } },
      orderBy: { version: 'asc' }
    });
  }
}

// Rebuild state from events
class BankAccount {
  constructor() {
    this.balance = 0;
    this.status = 'ACTIVE';
  }

  apply(event) {
    switch (event.type) {
      case 'ACCOUNT_CREATED':
        this.status = 'ACTIVE';
        break;
      case 'MONEY_DEPOSITED':
        this.balance += event.data.amount;
        break;
      case 'MONEY_WITHDRAWN':
        this.balance -= event.data.amount;
        break;
      case 'ACCOUNT_CLOSED':
        this.status = 'CLOSED';
        break;
    }
  }

  static fromEvents(events) {
    const account = new BankAccount();
    events.forEach(e => account.apply(e));
    return account;
  }
}

Event sourcing stores every change as an immutable event. The current state is derived by replaying events. This provides a complete audit trail and enables time-travel queries (state at any point in history).

CQRS Pattern

// CQRS: Separate write and read models

// WRITE SIDE (Commands)
class OrderCommandHandler {
  async createOrder(command) {
    // Validate business rules
    const inventory = await inventoryService.check(command.items);
    if (!inventory.available) throw new Error('Out of stock');

    // Append event to event store (write model)
    await eventStore.append(`order:${command.orderId}`, {
      type: 'ORDER_CREATED',
      data: {
        orderId: command.orderId,
        userId: command.userId,
        items: command.items,
        total: command.total
      }
    });
  }
}

// READ SIDE (Queries) - optimized denormalized views
// Event handler builds read model from events
class OrderReadModelUpdater {
  async handle(event) {
    switch (event.type) {
      case 'ORDER_CREATED':
        // Insert into read-optimized table
        await db.orderViews.create({
          orderId: event.data.orderId,
          userId: event.data.userId,
          total: event.data.total,
          itemCount: event.data.items.length,
          status: 'PENDING',
          createdAt: event.timestamp
        });
        break;

      case 'ORDER_SHIPPED':
        await db.orderViews.update(event.data.orderId, {
          status: 'SHIPPED',
          shippedAt: event.timestamp
        });
        break;
    }
  }
}

// Query the read model (fast, denormalized)
class OrderQueryHandler {
  async getUserOrders(userId, page) {
    return db.orderViews.findMany({
      where: { userId },
      orderBy: { createdAt: 'desc' },
      skip: page * 20,
      take: 20
    });
    // No JOINs needed - read model is denormalized
  }
}

CQRS separates writes (event store, business logic) from reads (denormalized views, fast queries). The read model is rebuilt from events, allowing independent optimization of both sides.

Saga Pattern for Distributed Transactions

// Saga: coordinate multi-service transactions with compensating actions

// Order placement saga:
// 1. Create Order -> 2. Reserve Inventory -> 3. Charge Payment -> 4. Confirm
// If step 3 fails: compensate step 2 (release inventory) and step 1 (cancel order)

class OrderSaga {
  constructor(eventBus) {
    this.eventBus = eventBus;
    this.setupHandlers();
  }

  setupHandlers() {
    this.eventBus.on('ORDER_CREATED', this.handleOrderCreated.bind(this));
    this.eventBus.on('INVENTORY_RESERVED', this.handleInventoryReserved.bind(this));
    this.eventBus.on('INVENTORY_RESERVATION_FAILED', this.handleInventoryFailed.bind(this));
    this.eventBus.on('PAYMENT_CHARGED', this.handlePaymentCharged.bind(this));
    this.eventBus.on('PAYMENT_FAILED', this.handlePaymentFailed.bind(this));
  }

  async handleOrderCreated(event) {
    // Step 2: Reserve inventory
    await this.eventBus.publish('RESERVE_INVENTORY', {
      orderId: event.orderId,
      items: event.items
    });
  }

  async handleInventoryReserved(event) {
    // Step 3: Charge payment
    await this.eventBus.publish('CHARGE_PAYMENT', {
      orderId: event.orderId,
      amount: event.total
    });
  }

  async handlePaymentCharged(event) {
    // Step 4: All good - confirm order
    await this.eventBus.publish('CONFIRM_ORDER', {
      orderId: event.orderId
    });
  }

  // COMPENSATING ACTIONS (rollback)
  async handlePaymentFailed(event) {
    // Payment failed -> release inventory
    await this.eventBus.publish('RELEASE_INVENTORY', {
      orderId: event.orderId,
      items: event.items
    });
    // Cancel order
    await this.eventBus.publish('CANCEL_ORDER', {
      orderId: event.orderId,
      reason: 'Payment failed'
    });
  }

  async handleInventoryFailed(event) {
    // Inventory unavailable -> cancel order
    await this.eventBus.publish('CANCEL_ORDER', {
      orderId: event.orderId,
      reason: 'Out of stock'
    });
  }
}

The saga pattern replaces distributed ACID transactions with a sequence of local transactions and compensating actions. If any step fails, previous steps are rolled back via compensation events.

Key points

Concepts covered

Event Sourcing, CQRS, Event Bus, Saga Pattern, Domain Events