Event Sourcing: State from Event History

Most applications store current state. When a user updates their profile, you overwrite the old values with new ones. When money moves between accounts, you update the balances. The previous state is...

Key Insights

  • Event sourcing stores every state change as an immutable event, giving you a complete audit trail and the ability to reconstruct state at any point in time
  • Current state is derived by replaying events through an aggregate, with snapshots providing a performance optimization for aggregates with long event histories
  • Projections transform event streams into denormalized read models optimized for specific query patterns, enabling CQRS architectures

What is Event Sourcing?

Most applications store current state. When a user updates their profile, you overwrite the old values with new ones. When money moves between accounts, you update the balances. The previous state is gone forever.

Event sourcing flips this model. Instead of storing current state, you store the sequence of events that led to that state. A bank account isn’t a row with a balance field—it’s a stream of AccountOpened, MoneyDeposited, and MoneyWithdrawn events. Current state becomes a derived value, computed by replaying those events.

This append-only approach means you never lose information. Every state change is preserved, timestamped, and immutable. You can reconstruct the state of any entity at any point in time. You can answer questions you didn’t know to ask when you designed the system.

The trade-off is complexity. Event sourcing requires different thinking, different infrastructure, and careful consideration of when the benefits justify the costs.

Core Concepts and Terminology

Events are immutable facts about something that happened. They’re named in past tense: OrderPlaced, PaymentReceived, ItemShipped. Each event contains the data needed to understand what changed.

Aggregates are consistency boundaries—entities that enforce business rules and emit events. An aggregate processes commands and produces events. It also rebuilds its state by replaying past events.

Event Store is the append-only database holding all events. It supports writing new events and reading events by aggregate ID. Think of it as a specialized log.

Projections (also called read models or views) are denormalized representations built by processing event streams. They’re optimized for specific query patterns and can be rebuilt from scratch by replaying events.

Snapshots are periodic captures of aggregate state, used to avoid replaying thousands of events every time you load an aggregate.

Let’s start with events:

interface DomainEvent {
  readonly eventId: string;
  readonly aggregateId: string;
  readonly timestamp: Date;
  readonly version: number;
}

class AccountCreated implements DomainEvent {
  readonly eventType = 'AccountCreated';
  
  constructor(
    public readonly eventId: string,
    public readonly aggregateId: string,
    public readonly timestamp: Date,
    public readonly version: number,
    public readonly ownerName: string,
    public readonly initialBalance: number
  ) {}
}

class MoneyDeposited implements DomainEvent {
  readonly eventType = 'MoneyDeposited';
  
  constructor(
    public readonly eventId: string,
    public readonly aggregateId: string,
    public readonly timestamp: Date,
    public readonly version: number,
    public readonly amount: number,
    public readonly description: string
  ) {}
}

class MoneyWithdrawn implements DomainEvent {
  readonly eventType = 'MoneyWithdrawn';
  
  constructor(
    public readonly eventId: string,
    public readonly aggregateId: string,
    public readonly timestamp: Date,
    public readonly version: number,
    public readonly amount: number,
    public readonly description: string
  ) {}
}

type AccountEvent = AccountCreated | MoneyDeposited | MoneyWithdrawn;

Events are data classes with no behavior. They capture what happened, not what to do about it.

Building an Event-Sourced Aggregate

The aggregate is where business logic lives. It validates commands, enforces invariants, and produces events. It also knows how to rebuild itself from event history.

class BankAccount {
  private _id: string = '';
  private _ownerName: string = '';
  private _balance: number = 0;
  private _version: number = 0;
  private _changes: AccountEvent[] = [];

  get id(): string { return this._id; }
  get balance(): number { return this._balance; }
  get version(): number { return this._version; }
  get uncommittedChanges(): AccountEvent[] { return [...this._changes]; }

  // Command handlers produce events
  static create(id: string, ownerName: string, initialBalance: number): BankAccount {
    if (initialBalance < 0) {
      throw new Error('Initial balance cannot be negative');
    }
    
    const account = new BankAccount();
    const event = new AccountCreated(
      crypto.randomUUID(),
      id,
      new Date(),
      1,
      ownerName,
      initialBalance
    );
    account.applyChange(event);
    return account;
  }

  deposit(amount: number, description: string): void {
    if (amount <= 0) {
      throw new Error('Deposit amount must be positive');
    }
    
    const event = new MoneyDeposited(
      crypto.randomUUID(),
      this._id,
      new Date(),
      this._version + 1,
      amount,
      description
    );
    this.applyChange(event);
  }

  withdraw(amount: number, description: string): void {
    if (amount <= 0) {
      throw new Error('Withdrawal amount must be positive');
    }
    if (amount > this._balance) {
      throw new Error('Insufficient funds');
    }
    
    const event = new MoneyWithdrawn(
      crypto.randomUUID(),
      this._id,
      new Date(),
      this._version + 1,
      amount,
      description
    );
    this.applyChange(event);
  }

  // Apply methods update internal state
  private apply(event: AccountEvent): void {
    switch (event.eventType) {
      case 'AccountCreated':
        this._id = event.aggregateId;
        this._ownerName = event.ownerName;
        this._balance = event.initialBalance;
        break;
      case 'MoneyDeposited':
        this._balance += event.amount;
        break;
      case 'MoneyWithdrawn':
        this._balance -= event.amount;
        break;
    }
    this._version = event.version;
  }

  private applyChange(event: AccountEvent): void {
    this.apply(event);
    this._changes.push(event);
  }

  // Rehydrate from event history
  static rehydrate(events: AccountEvent[]): BankAccount {
    if (events.length === 0) {
      throw new Error('Cannot rehydrate from empty event stream');
    }
    
    const account = new BankAccount();
    for (const event of events) {
      account.apply(event);
    }
    return account;
  }

  markChangesAsCommitted(): void {
    this._changes = [];
  }
}

The pattern separates command handling (validation, business rules) from state changes (apply methods). Apply methods must be pure—no validation, no side effects, just state updates. This ensures replaying events always produces the same result.

The Event Store

The event store is conceptually simple: append events, retrieve events by aggregate ID. The complexity comes from concurrency control and durability guarantees.

interface StoredEvent {
  eventId: string;
  aggregateId: string;
  eventType: string;
  version: number;
  timestamp: Date;
  payload: string;
}

class InMemoryEventStore {
  private events: Map<string, StoredEvent[]> = new Map();

  async append(
    aggregateId: string, 
    events: DomainEvent[], 
    expectedVersion: number
  ): Promise<void> {
    const existingEvents = this.events.get(aggregateId) || [];
    const currentVersion = existingEvents.length > 0 
      ? existingEvents[existingEvents.length - 1].version 
      : 0;

    // Optimistic concurrency check
    if (currentVersion !== expectedVersion) {
      throw new Error(
        `Concurrency conflict: expected version ${expectedVersion}, ` +
        `but current version is ${currentVersion}`
      );
    }

    const storedEvents: StoredEvent[] = events.map(event => ({
      eventId: event.eventId,
      aggregateId: event.aggregateId,
      eventType: (event as any).eventType,
      version: event.version,
      timestamp: event.timestamp,
      payload: JSON.stringify(event)
    }));

    this.events.set(aggregateId, [...existingEvents, ...storedEvents]);
  }

  async getEvents(aggregateId: string, afterVersion: number = 0): Promise<StoredEvent[]> {
    const events = this.events.get(aggregateId) || [];
    return events.filter(e => e.version > afterVersion);
  }

  async getAllEvents(): Promise<StoredEvent[]> {
    return Array.from(this.events.values()).flat().sort(
      (a, b) => a.timestamp.getTime() - b.timestamp.getTime()
    );
  }
}

The expectedVersion parameter enables optimistic concurrency. If two processes try to modify the same aggregate simultaneously, one will fail with a concurrency conflict. This prevents lost updates without pessimistic locking.

Production event stores like EventStoreDB, Marten, or Axon Server add features like subscriptions, projections, and clustering. But the core operations remain the same.

Projections: Building Read Models

Event sourcing naturally separates writes (events) from reads (projections). This is CQRS in action. Projections subscribe to event streams and build denormalized views optimized for specific queries.

interface AccountSummary {
  accountId: string;
  ownerName: string;
  balance: number;
  transactionCount: number;
  lastActivityAt: Date;
}

class AccountSummaryProjection {
  private summaries: Map<string, AccountSummary> = new Map();

  async handle(event: StoredEvent): Promise<void> {
    const payload = JSON.parse(event.payload);
    
    switch (event.eventType) {
      case 'AccountCreated':
        this.summaries.set(event.aggregateId, {
          accountId: event.aggregateId,
          ownerName: payload.ownerName,
          balance: payload.initialBalance,
          transactionCount: 0,
          lastActivityAt: new Date(event.timestamp)
        });
        break;
        
      case 'MoneyDeposited':
      case 'MoneyWithdrawn': {
        const summary = this.summaries.get(event.aggregateId);
        if (summary) {
          const delta = event.eventType === 'MoneyDeposited' 
            ? payload.amount 
            : -payload.amount;
          summary.balance += delta;
          summary.transactionCount++;
          summary.lastActivityAt = new Date(event.timestamp);
        }
        break;
      }
    }
  }

  getByAccountId(accountId: string): AccountSummary | undefined {
    return this.summaries.get(accountId);
  }

  getHighValueAccounts(minBalance: number): AccountSummary[] {
    return Array.from(this.summaries.values())
      .filter(s => s.balance >= minBalance);
  }
}

Projections can be synchronous (updated in the same transaction as event persistence) or asynchronous (updated via background workers consuming event streams). Asynchronous projections offer better write performance but introduce eventual consistency—reads might not reflect the latest writes immediately.

The power of projections is that you can create multiple views from the same events. Need a monthly transaction report? Build a projection. Need a fraud detection view? Build another. The event stream is your single source of truth.

Snapshots for Performance

Replaying thousands of events to load an aggregate gets expensive. Snapshots solve this by periodically capturing aggregate state.

interface Snapshot {
  aggregateId: string;
  version: number;
  timestamp: Date;
  state: string;
}

class SnapshotStore {
  private snapshots: Map<string, Snapshot> = new Map();

  async save(aggregateId: string, version: number, state: object): Promise<void> {
    this.snapshots.set(aggregateId, {
      aggregateId,
      version,
      timestamp: new Date(),
      state: JSON.stringify(state)
    });
  }

  async getLatest(aggregateId: string): Promise<Snapshot | undefined> {
    return this.snapshots.get(aggregateId);
  }
}

class BankAccountRepository {
  constructor(
    private eventStore: InMemoryEventStore,
    private snapshotStore: SnapshotStore,
    private snapshotFrequency: number = 50
  ) {}

  async load(accountId: string): Promise<BankAccount> {
    const snapshot = await this.snapshotStore.getLatest(accountId);
    
    let account: BankAccount;
    let afterVersion = 0;
    
    if (snapshot) {
      // Restore from snapshot
      const state = JSON.parse(snapshot.state);
      account = Object.assign(new BankAccount(), state);
      afterVersion = snapshot.version;
    }
    
    // Replay events after snapshot
    const events = await this.eventStore.getEvents(accountId, afterVersion);
    
    if (!snapshot && events.length === 0) {
      throw new Error(`Account ${accountId} not found`);
    }
    
    if (snapshot) {
      for (const stored of events) {
        const event = JSON.parse(stored.payload) as AccountEvent;
        (account as any).apply(event);
      }
    } else {
      account = BankAccount.rehydrate(
        events.map(e => JSON.parse(e.payload) as AccountEvent)
      );
    }
    
    return account;
  }

  async save(account: BankAccount): Promise<void> {
    const changes = account.uncommittedChanges;
    if (changes.length === 0) return;

    const expectedVersion = account.version - changes.length;
    await this.eventStore.append(account.id, changes, expectedVersion);
    
    // Create snapshot if needed
    if (account.version % this.snapshotFrequency === 0) {
      await this.snapshotStore.save(account.id, account.version, {
        _id: account.id,
        _balance: account.balance,
        _version: account.version
      });
    }
    
    account.markChangesAsCommitted();
  }
}

Snapshot frequency is a tuning parameter. Too frequent wastes storage; too infrequent slows loading. Start with something like every 100 events and adjust based on actual performance.

Trade-offs and When to Use Event Sourcing

Event sourcing shines in specific contexts:

Financial systems need complete audit trails. Event sourcing provides this by design—every state change is recorded with timestamp and causation.

Collaborative applications benefit from event-based conflict resolution. Instead of last-write-wins, you can merge concurrent changes intelligently.

Debugging and support become easier when you can replay exactly what happened. No more guessing why data looks wrong.

Temporal queries are trivial. What was this account’s balance on March 15th? Replay events up to that date.

But event sourcing adds complexity:

Schema evolution requires careful handling. Old events must remain readable even as your domain model evolves. Upcasting (transforming old event formats to new ones during replay) is common but adds maintenance burden.

Eventual consistency is inherent when using asynchronous projections. Your read models will lag behind writes. This is fine for many use cases but problematic for others.

Learning curve is steep. Teams need to think differently about state, embrace immutability, and understand event-driven patterns.

Don’t use event sourcing for simple CRUD applications. The overhead isn’t justified. Use it when you genuinely need the audit trail, temporal queries, or the flexibility to build multiple views from a single event stream.

Start small. Pick one bounded context where event sourcing provides clear value. Learn the patterns, understand the operational requirements, and expand from there.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.