Skip to content

Event Sourcing in Practice: Patterns and Pitfalls

A practical guide to event sourcing — when to use it, how to implement event stores, build projections, handle schema evolution, and avoid common pitfalls.

4 min read
Event sourcing timeline showing events appended to a stream and projected into read models

Event sourcing stores state as a sequence of events rather than a mutable current state. Instead of updating a row in a database, you append an event describing what happened. The current state is derived by replaying events. This gives you a complete audit trail, the ability to rebuild state at any point in time, and temporal queries that traditional CRUD cannot answer.

It also introduces significant complexity. Event sourcing is not appropriate for every domain. This guide covers when it works, how to implement it, and the pitfalls that catch teams by surprise.

When Event Sourcing Makes Sense

Event sourcing shines in domains where the history of changes is as valuable as the current state.

Good fits:
- Financial systems — every transaction must be auditable
- Inventory management — track every stock movement
- Booking/reservation systems — cancellations and modifications matter
- Collaborative editing — track every change by every user
- Compliance-heavy domains — regulatory audit trails required

Poor fits:
- Simple CRUD applications — blog posts, user profiles
- High-throughput writes with no audit needs — telemetry data
- Systems where current state is all that matters

The test: if your business stakeholders would find value in answering "what was the state of X at time T?" or "what sequence of actions led to this state?", event sourcing is worth considering.

The Event Store

An event store is an append-only log where each stream represents an entity's history. Events are immutable — once written, they never change.

tstypescript
interface DomainEvent {
  eventId: string;
  streamId: string;
  eventType: string;
  data: Record<string, unknown>;
  metadata: {
    timestamp: string;
    userId: string;
    correlationId: string;
  };
  version: number;
}
 
// ❌ Mutable state — no history, no audit trail
await db.orders.update(
  { _id: orderId },
  { $set: { status: 'shipped', shippedAt: new Date() } }
);
// Previous state is gone forever
tstypescript
// ✅ Event sourcing — append events, derive state
const events: DomainEvent[] = [
  {
    eventId: 'evt-001',
    streamId: 'order-123',
    eventType: 'OrderPlaced',
    data: {
      customerId: 'cust-456',
      items: [{ productId: 'prod-1', quantity: 2, price: 29.99 }],
      total: 59.98,
    },
    metadata: { timestamp: '2021-08-01T10:00:00Z', userId: 'cust-456', correlationId: 'req-789' },
    version: 1,
  },
  {
    eventId: 'evt-002',
    streamId: 'order-123',
    eventType: 'PaymentProcessed',
    data: { paymentId: 'pay-001', amount: 59.98, method: 'card' },
    metadata: { timestamp: '2021-08-01T10:00:05Z', userId: 'system', correlationId: 'req-789' },
    version: 2,
  },
  {
    eventId: 'evt-003',
    streamId: 'order-123',
    eventType: 'OrderShipped',
    data: { trackingNumber: '1Z999AA10123456784', carrier: 'UPS' },
    metadata: { timestamp: '2021-08-01T14:30:00Z', userId: 'staff-001', correlationId: 'req-912' },
    version: 3,
  },
];

Each event captures what happened, when, and who triggered it. The version field enables optimistic concurrency — two concurrent writes to the same stream are detected and one is rejected.

Rebuilding State from Events

An aggregate loads its event stream and applies each event to reconstruct its current state.

tstypescript
interface OrderState {
  id: string;
  status: 'placed' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
  items: Array<{ productId: string; quantity: number; price: number }>;
  total: number;
  trackingNumber?: string;
}
 
function applyEvent(state: OrderState, event: DomainEvent): OrderState {
  switch (event.eventType) {
    case 'OrderPlaced':
      return {
        id: event.streamId,
        status: 'placed',
        items: event.data.items as OrderState['items'],
        total: event.data.total as number,
      };
 
    case 'PaymentProcessed':
      return { ...state, status: 'paid' };
 
    case 'OrderShipped':
      return {
        ...state,
        status: 'shipped',
        trackingNumber: event.data.trackingNumber as string,
      };
 
    case 'OrderDelivered':
      return { ...state, status: 'delivered' };
 
    case 'OrderCancelled':
      return { ...state, status: 'cancelled' };
 
    default:
      return state;
  }
}
 
function rehydrate(events: DomainEvent[]): OrderState {
  return events.reduce(
    (state, event) => applyEvent(state, event),
    {} as OrderState
  );
}
 
// Usage:
const orderEvents = await eventStore.getStream('order-123');
const currentState = rehydrate(orderEvents);
// { id: 'order-123', status: 'shipped', total: 59.98, trackingNumber: '1Z...' }

The applyEvent function is a pure function — given the same events, it always produces the same state. This makes it easy to test and reason about.

Projections: Read Models

Replaying events for every read is expensive. Projections build optimized read models by subscribing to events and maintaining denormalized views.

tstypescript
// Projection: Order summary view (optimized for listing orders)
interface OrderSummary {
  orderId: string;
  customerName: string;
  total: number;
  status: string;
  itemCount: number;
  lastUpdated: string;
}
 
class OrderSummaryProjection {
  constructor(private db: Database) {}
 
  async handle(event: DomainEvent): Promise<void> {
    switch (event.eventType) {
      case 'OrderPlaced':
        await this.db.orderSummaries.insert({
          orderId: event.streamId,
          customerName: event.data.customerName,
          total: event.data.total,
          status: 'placed',
          itemCount: (event.data.items as unknown[]).length,
          lastUpdated: event.metadata.timestamp,
        });
        break;
 
      case 'OrderShipped':
        await this.db.orderSummaries.update(
          { orderId: event.streamId },
          {
            $set: {
              status: 'shipped',
              lastUpdated: event.metadata.timestamp,
            },
          }
        );
        break;
 
      case 'OrderCancelled':
        await this.db.orderSummaries.update(
          { orderId: event.streamId },
          {
            $set: {
              status: 'cancelled',
              lastUpdated: event.metadata.timestamp,
            },
          }
        );
        break;
    }
  }
}
 
// Multiple projections from the same events:
// - OrderSummaryProjection → order list page
// - CustomerOrderHistoryProjection → customer profile page
// - RevenueReportProjection → analytics dashboard

Projections can be rebuilt from scratch by replaying all events. This means you can add new read models retroactively — create a projection that counts orders by region, replay events from the beginning, and the new view is immediately populated with historical data.

Schema Evolution

Events are immutable, but your domain model evolves. New event versions need to coexist with old ones.

tstypescript
// ❌ Breaking change — old events in the store can't be read
interface OrderPlacedV2 {
  eventType: 'OrderPlaced';
  data: {
    customerId: string;
    shippingAddress: Address;   // New required field
    items: OrderItem[];
    total: number;
  };
}
 
// ✅ Upcasting — transform old events to the current schema
function upcast(event: DomainEvent): DomainEvent {
  if (event.eventType === 'OrderPlaced' && !event.data.shippingAddress) {
    return {
      ...event,
      data: {
        ...event.data,
        shippingAddress: {
          street: 'Unknown',
          city: 'Unknown',
          country: 'Unknown',
        },
      },
    };
  }
  return event;
}
 
// Apply upcasting when reading events
async function getStream(streamId: string): Promise<DomainEvent[]> {
  const rawEvents = await eventStore.read(streamId);
  return rawEvents.map(upcast);
}

Upcasting transforms old event shapes into the current expected shape at read time. The stored events remain unchanged — the transformation is applied in memory when loading the stream.

Snapshots for Long Streams

Streams with thousands of events become expensive to replay. Snapshots store a checkpoint of the current state at a specific version.

tstypescript
interface Snapshot<T> {
  streamId: string;
  state: T;
  version: number;       // The event version this snapshot reflects
  createdAt: string;
}
 
async function loadAggregate(streamId: string): Promise<OrderState> {
  // Try loading from snapshot first
  const snapshot = await snapshotStore.get<OrderState>(streamId);
 
  let state: OrderState;
  let fromVersion: number;
 
  if (snapshot) {
    state = snapshot.state;
    fromVersion = snapshot.version + 1;
  } else {
    state = {} as OrderState;
    fromVersion = 1;
  }
 
  // Load only events after the snapshot
  const events = await eventStore.getStream(streamId, { fromVersion });
  const currentState = events.reduce(
    (s, event) => applyEvent(s, event),
    state
  );
 
  // Save new snapshot if enough events accumulated
  const totalEvents = (snapshot?.version ?? 0) + events.length;
  if (events.length > 100) {
    await snapshotStore.save({
      streamId,
      state: currentState,
      version: totalEvents,
      createdAt: new Date().toISOString(),
    });
  }
 
  return currentState;
}

Snapshots are an optimization, not a requirement. The system must work correctly without them — they just reduce the number of events replayed per read.

Key Takeaways

  1. Event sourcing stores what happened, not what the current state is — state is derived by replaying events
  2. Use it for audit-heavy domains where history, temporal queries, and audit trails have business value
  3. Projections build optimized read models from event streams — you can add new projections retroactively
  4. Handle schema evolution through upcasting — transform old events to the current shape at read time
  5. Snapshots optimize long streams — checkpoint state periodically to avoid replaying thousands of events
  6. Do not default to event sourcing — the complexity cost is real, use it where the benefits justify it
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX