In a traditional system, when a user changes their shipping address, you update the address column in the database. The old address is gone. If someone asks “what was the address when the order was placed?”, you’re out of luck — unless you built an audit log from the start.

Event Sourcing flips the model: instead of storing the current state, you store every event that ever happened. Current state is derived by replaying those events. The database isn’t a snapshot — it’s a ledger.

The Core Idea

Think of a bank account. Traditional approach: store the balance. Event Sourcing approach: store every transaction.

// Traditional: mutable state
interface Account {
  id: string;
  balance: number; // Just the current number
}

// Event Sourcing: immutable history
type AccountEvent =
  | { type: 'AccountOpened'; amount: number; timestamp: Date }
  | { type: 'MoneyDeposited'; amount: number; timestamp: Date }
  | { type: 'MoneyWithdrawn'; amount: number; timestamp: Date };

const events: AccountEvent[] = [
  { type: 'AccountOpened', amount: 0, timestamp: new Date('2024-01-01') },
  { type: 'MoneyDeposited', amount: 1000, timestamp: new Date('2024-01-15') },
  { type: 'MoneyWithdrawn', amount: 200, timestamp: new Date('2024-02-01') },
  { type: 'MoneyDeposited', amount: 500, timestamp: new Date('2024-02-10') },
];

To get the current balance, you replay the events:

function replayAccount(events: AccountEvent[]): { balance: number } {
  return events.reduce(
    (state, event) => {
      switch (event.type) {
        case 'AccountOpened':
          return { balance: event.amount };
        case 'MoneyDeposited':
          return { balance: state.balance + event.amount };
        case 'MoneyWithdrawn':
          return { balance: state.balance - event.amount };
      }
    },
    { balance: 0 }
  );
}

const state = replayAccount(events); // { balance: 1300 }

Implementing an Event Store

The event store is the core of Event Sourcing. Events are append-only and immutable:

interface StoredEvent {
  id: string;
  aggregateId: string;
  aggregateType: string;
  eventType: string;
  payload: unknown;
  version: number; // Sequence number per aggregate
  occurredAt: Date;
}

class EventStore {
  async append(
    aggregateId: string,
    events: DomainEvent[],
    expectedVersion: number
  ): Promise<void> {
    await db.transaction(async (trx) => {
      // Optimistic concurrency check
      const currentVersion = await trx('events')
        .where({ aggregateId })
        .max('version as version')
        .first();

      if ((currentVersion?.version ?? -1) !== expectedVersion) {
        throw new ConcurrencyError(`Expected version ${expectedVersion}`);
      }

      const rows = events.map((event, i) => ({
        id: uuid(),
        aggregateId,
        eventType: event.type,
        payload: JSON.stringify(event.payload),
        version: expectedVersion + i + 1,
        occurredAt: new Date(),
      }));

      await trx('events').insert(rows);
    });
  }

  async load(aggregateId: string): Promise<StoredEvent[]> {
    return db('events')
      .where({ aggregateId })
      .orderBy('version', 'asc');
  }
}

Building an Aggregate

Aggregates in Event Sourcing apply events to themselves:

class BankAccount {
  private events: DomainEvent[] = [];
  private balance = 0;
  private version = -1;

  static fromHistory(events: StoredEvent[]): BankAccount {
    const account = new BankAccount();
    for (const event of events) {
      account.apply(JSON.parse(event.payload as string));
      account.version = event.version;
    }
    return account;
  }

  deposit(amount: number): void {
    if (amount <= 0) throw new Error('Amount must be positive');
    this.applyAndRecord({ type: 'MoneyDeposited', payload: { amount } });
  }

  withdraw(amount: number): void {
    if (amount > this.balance) throw new Error('Insufficient funds');
    this.applyAndRecord({ type: 'MoneyWithdrawn', payload: { amount } });
  }

  private applyAndRecord(event: DomainEvent): void {
    this.apply(event);
    this.events.push(event);
  }

  private apply(event: DomainEvent): void {
    switch (event.type) {
      case 'MoneyDeposited':
        this.balance += event.payload.amount;
        break;
      case 'MoneyWithdrawn':
        this.balance -= event.payload.amount;
        break;
    }
  }

  getUncommittedEvents(): DomainEvent[] {
    return [...this.events];
  }
}

Projections: Reading State Efficiently

Replaying all events every time a balance is needed would be slow. Projections (also called read models) maintain a pre-computed view that updates as new events arrive:

// A projection that maintains a balance table
class AccountBalanceProjection {
  async on(event: StoredEvent): Promise<void> {
    const payload = JSON.parse(event.payload as string);

    switch (event.eventType) {
      case 'AccountOpened':
        await db('account_balances').insert({
          accountId: event.aggregateId,
          balance: payload.initialDeposit ?? 0,
        });
        break;

      case 'MoneyDeposited':
        await db('account_balances')
          .where({ accountId: event.aggregateId })
          .increment('balance', payload.amount);
        break;

      case 'MoneyWithdrawn':
        await db('account_balances')
          .where({ accountId: event.aggregateId })
          .decrement('balance', payload.amount);
        break;
    }
  }
}

Projections can be rebuilt at any time by replaying all events — a powerful property for bug fixes and new features.

Snapshots

For aggregates with thousands of events, replaying from scratch is slow. Snapshots capture state at a point in time:

async function loadAccountWithSnapshot(accountId: string): Promise<BankAccount> {
  const snapshot = await snapshotStore.latest(accountId);
  
  const events = await eventStore.load(accountId, {
    fromVersion: snapshot ? snapshot.version + 1 : 0,
  });

  if (snapshot) {
    return BankAccount.fromSnapshot(snapshot, events);
  }
  return BankAccount.fromHistory(events);
}

Save a snapshot every N events (e.g., every 100).

What You Get for Free

Complete audit log: every change is a first-class event. No extra audit log code needed.

Time travel: replay events up to any point in time to see historical state.

New projections: need a new read model? Replay all events through a new projection. You don’t lose data you never thought you’d need.

Event-driven integration: your event stream is a natural feed for other services.

When NOT to Use Event Sourcing

Event Sourcing is powerful but adds significant complexity:

  • Simple CRUD apps don’t need it
  • Querying is harder (you need projections for every read model)
  • Eventual consistency between write model and projections must be tolerated
  • Schema evolution (changing event shapes over time) requires care

Start without it. Add it when you need the audit trail, time travel, or event-driven architecture that comes with it.

Key Takeaways

  • Event Sourcing stores immutable events instead of mutable state
  • Current state is derived by replaying events
  • Projections maintain efficient read models from the event stream
  • Snapshots avoid replaying all events for large aggregates
  • You get audit logs, time travel, and event-driven integration for free
  • It adds complexity — only use it when the benefits justify the cost