Skip to main content
Event Sourcing: When State Isn't Enough

Event Sourcing: When State Isn't Enough

Aug 24, 2025

Most apps store current state. User has $100. Order is shipped. Product has 50 in stock.

Event sourcing flips this. You store every change that happened. User deposited 50,then50, then 50 more. Order was created, then paid, then shipped.

Sounds like extra work? Sometimes it is. But sometimes its exactly what you need.

Traditional vs Event Sourced

With events, you get full history. You know exactly how you got here.

When To Use It

Good fit:

  • Financial systems (audit trails)
  • Booking/reservation systems
  • Collaboration tools (like Google Docs)
  • Systems needing "time travel"

Not worth it:

  • Simple CRUD apps
  • Systems without audit needs
  • When history doesnt matter

Basic Implementation

// Events are immutable facts
interface Event {
  id: string;
  type: string;
  data: unknown;
  timestamp: Date;
  aggregateId: string;
}

// Example events for an order
type OrderEvent =
  | { type: 'OrderCreated'; data: { items: Item[]; customerId: string } }
  | { type: 'PaymentReceived'; data: { amount: number; method: string } }
  | { type: 'OrderShipped'; data: { trackingNumber: string } }
  | { type: 'OrderDelivered'; data: { signature: string } };

The Event Store

class EventStore {
  async append(aggregateId: string, events: Event[]) {
    // Events are append-only - never update or delete
    await db.insert('events', events.map(e => ({
      ...e,
      aggregateId,
      timestamp: new Date()
    })));
  }

  async getEvents(aggregateId: string): Promise<Event[]> {
    return db.query(
      'SELECT * FROM events WHERE aggregate_id = ? ORDER BY timestamp',
      [aggregateId]
    );
  }
}

Rebuilding State

Current state comes from replaying events:

class Order {
  status: string = 'pending';
  items: Item[] = [];
  total: number = 0;
  trackingNumber?: string;

  // Apply events to build state
  apply(event: OrderEvent) {
    switch (event.type) {
      case 'OrderCreated':
        this.items = event.data.items;
        this.total = this.items.reduce((sum, i) => sum + i.price, 0);
        break;
      case 'PaymentReceived':
        this.status = 'paid';
        break;
      case 'OrderShipped':
        this.status = 'shipped';
        this.trackingNumber = event.data.trackingNumber;
        break;
      case 'OrderDelivered':
        this.status = 'delivered';
        break;
    }
  }

  static fromEvents(events: OrderEvent[]): Order {
    const order = new Order();
    events.forEach(e => order.apply(e));
    return order;
  }
}

Snapshots for Performance

Replaying thousands of events gets slow. Take snapshots:

class OrderRepository {
  async get(orderId: string): Promise<Order> {
    // Try to load from snapshot first
    const snapshot = await this.getSnapshot(orderId);
    const fromVersion = snapshot?.version ?? 0;

    // Only replay events after snapshot
    const events = await eventStore.getEvents(orderId, fromVersion);

    const order = snapshot?.state ?? new Order();
    events.forEach(e => order.apply(e));

    return order;
  }

  async saveSnapshot(orderId: string, order: Order, version: number) {
    await db.upsert('snapshots', {
      aggregateId: orderId,
      state: JSON.stringify(order),
      version
    });
  }
}

CQRS: Separate Read and Write

Event sourcing pairs well with CQRS (Command Query Responsibility Segregation):

Write side deals with events. Read side maintains optimized views for queries.

The Gotchas

  1. Schema evolution - Events live forever. Changing their shape is tricky.
  2. Eventually consistent - Read models lag behind writes.
  3. Complexity - More moving parts than simple CRUD.
  4. Debugging - Following event chains takes practice.

Further Reading

Event sourcing isnt for everything. But when you need complete history, auditability, or the ability to rebuild state at any point in time - its incredibly powerful.

© 2026 Tawan. All rights reserved.