DEV Community

Young Gao
Young Gao

Posted on

Event Sourcing Explained: When CRUD Is Not Enough (Practical Guide 2026)

Your database lies to you.

It shows you the current state of the world. But it can't tell you how you got there. Event sourcing fixes that.

CRUD: The Default That Falls Short

Traditional CRUD overwrites data. User changes their email? You UPDATE users SET email = 'new@example.com'. The old email is gone. Forever.

That works fine for a blog. It doesn't work when:

  • An auditor asks "who changed what, and when?"
  • A customer disputes a transaction from three months ago
  • You need to rebuild a read model you haven't invented yet
  • Your PM says "add undo"

Event Sourcing: Store Facts, Not State

Instead of storing the current state, you store every event that ever happened. The current state is derived by replaying those events.

// CRUD approach: one mutable row
await db.query('UPDATE accounts SET balance = balance - 100 WHERE id = $1', [accountId]);

// Event sourcing approach: append immutable facts
const event: DomainEvent = {
  type: 'MoneyWithdrawn',
  aggregateId: accountId,
  amount: 100,
  timestamp: Date.now(),
  metadata: { userId: 'u_382', reason: 'ATM withdrawal' }
};

await eventStore.append(event);
Enter fullscreen mode Exit fullscreen mode

The account balance? You get it by replaying every MoneyDeposited and MoneyWithdrawn event for that account. The data is never lost, never overwritten.

Designing the Event Store

An event store is simpler than you think. At its core, it's an append-only log.

interface StoredEvent {
  id: string;
  aggregateId: string;
  type: string;
  data: Record<string, unknown>;
  metadata: Record<string, unknown>;
  version: number;       // per-aggregate sequence number
  timestamp: number;
}

class EventStore {
  async append(aggregateId: string, events: DomainEvent[], expectedVersion: number): Promise<void> {
    const current = await this.getVersion(aggregateId);
    if (current !== expectedVersion) {
      throw new ConcurrencyError(`Expected version ${expectedVersion}, got ${current}`);
    }
    // Append atomically
    await this.db.insertMany(
      events.map((e, i) => ({
        ...e,
        aggregateId,
        version: expectedVersion + i + 1,
        timestamp: Date.now()
      }))
    );
  }

  async getEvents(aggregateId: string, fromVersion = 0): Promise<StoredEvent[]> {
    return this.db.query(
      'SELECT * FROM events WHERE aggregate_id = $1 AND version > $2 ORDER BY version',
      [aggregateId, fromVersion]
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The expectedVersion check is critical. It's your optimistic concurrency control — two concurrent commands can't both succeed if they'd conflict.

Rebuilding State: The Aggregate

An aggregate replays events to hydrate itself:

class Account {
  private balance = 0;
  private version = 0;

  apply(event: StoredEvent): void {
    switch (event.type) {
      case 'MoneyDeposited':
        this.balance += event.data.amount as number;
        break;
      case 'MoneyWithdrawn':
        this.balance -= event.data.amount as number;
        break;
    }
    this.version = event.version;
  }

  static fromEvents(events: StoredEvent[]): Account {
    const account = new Account();
    events.forEach(e => account.apply(e));
    return account;
  }

  withdraw(amount: number): DomainEvent {
    if (amount > this.balance) throw new Error('Insufficient funds');
    return { type: 'MoneyWithdrawn', data: { amount } };
  }
}
Enter fullscreen mode Exit fullscreen mode

Business rules live in the aggregate. Events are the result of valid commands, not raw user input.

Projections: Build Any Read Model You Want

Replaying events per request is expensive. Projections solve this — they're event handlers that build optimized read models.

class AccountBalanceProjection {
  async handle(event: StoredEvent): Promise<void> {
    switch (event.type) {
      case 'MoneyDeposited':
        await this.db.query(
          'INSERT INTO account_balances (id, balance) VALUES ($1, $2) ON CONFLICT (id) DO UPDATE SET balance = balance + $2',
          [event.aggregateId, event.data.amount]
        );
        break;
      case 'MoneyWithdrawn':
        await this.db.query(
          'UPDATE account_balances SET balance = balance - $1 WHERE id = $2',
          [event.data.amount, event.aggregateId]
        );
        break;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The killer feature: you can create new projections at any time and replay the full event history to populate them. Need a new dashboard? A new search index? Replay and build.

Snapshotting: Don't Replay a Million Events

An account with 50,000 transactions shouldn't replay from event zero every time.

class SnapshottingRepository {
  private snapshotInterval = 100;

  async load(aggregateId: string): Promise<Account> {
    const snapshot = await this.snapshots.getLatest(aggregateId);
    const fromVersion = snapshot?.version ?? 0;
    const events = await this.eventStore.getEvents(aggregateId, fromVersion);

    const account = snapshot
      ? Account.fromSnapshot(snapshot.state)
      : new Account();

    events.forEach(e => account.apply(e));

    if (events.length >= this.snapshotInterval) {
      await this.snapshots.save(aggregateId, account.toSnapshot(), account.version);
    }
    return account;
  }
}
Enter fullscreen mode Exit fullscreen mode

Snapshot every N events. You're now replaying at most N events instead of all of history.

CQRS: Separate Reads From Writes

Event sourcing pairs naturally with CQRS (Command Query Responsibility Segregation). The write side handles commands and produces events. The read side consumes events and builds projections.

Command → Aggregate → Events → Event Store
                                    ↓
                              Projection Handler
                                    ↓
                              Read Database → Query API
Enter fullscreen mode Exit fullscreen mode

Why bother? Because your write model and read model have fundamentally different needs. Writes need consistency and business rules. Reads need speed and flexible shapes. Separating them means you can scale and optimize each independently.

When to Use Event Sourcing

Good fit:

  • Financial systems (every cent must be traceable)
  • Audit-heavy domains (healthcare, compliance)
  • Systems needing temporal queries ("what was the state on March 5th?")
  • Undo/redo or approval workflows
  • Event-driven architectures where you already publish events

Bad fit:

  • Simple CRUD apps with no audit requirements
  • High-write, low-read scenarios with no need for history
  • Teams without the bandwidth to learn the pattern

Common Mistakes

Putting business logic in event handlers. Events are facts that already happened. Validation belongs in the aggregate, before the event is emitted.

Making events too granular. FieldUpdated with a field name and value isn't an event — it's disguised CRUD. Events should express domain intent: OrderShipped, not OrderStatusUpdated.

Making events too coarse. AccountUpdated carrying the full state diff defeats the purpose. Be specific about what happened.

Forgetting idempotency on projections. Projections will sometimes process the same event twice (replays, crashes, retries). Use the event ID or version to deduplicate.

Skipping the concurrency check. Without optimistic concurrency on the aggregate version, you'll get corrupted state under load. Always check expectedVersion.

Exposing events as your public API. Internal domain events and external integration events should be different things. Internal events can change freely. External contracts need versioning and stability.

Never cleaning up snapshots. Old snapshots pile up. Set a retention policy — keep the latest two or three per aggregate and prune the rest.


Part of my Production Backend Patterns series. Follow for more practical backend engineering.




---

If this was useful, consider:
- [Sponsoring on GitHub](https://github.com/sponsors/NoPKT) to support more open-source tools
- [Buying me a coffee on Ko-fi](https://ko-fi.com/gps949)

---

## You Might Also Like

- [Database Indexes Explained: B-Trees, Composite Keys, and When Indexes Hurt Performance (2026)](https://dev.to/young_gao/database-indexes-explained-b-trees-composite-indexes-and-when-they-hurt-performance-3gac)
- [Multi-Tenant Architecture: Database Per Tenant vs Shared Schema — Pros and Cons (2026)](https://dev.to/young_gao/multi-tenant-architecture-database-per-tenant-vs-shared-schema-1n2e)
- [Database Migrations in Production: Zero-Downtime Schema Changes (2026 Guide)](https://dev.to/young_gao/database-migrations-in-production-zero-downtime-schema-changes-5fng)

*Follow me for more production-ready backend content!*

---

*If this helped you, [buy me a coffee on Ko-fi](https://ko-fi.com/gps949)!*
Enter fullscreen mode Exit fullscreen mode

Top comments (0)