Design Patterns

Event Sourcing Checklist: When It Makes Sense & Common Pitfalls

I remember the first time I convinced a team to try event sourcing. We were building odds for a betting system with lots of domain rules, projections, and...

17 Nov 2025

Event Sourcing Checklist: When It Makes Sense & Common Pitfalls

I remember the first time I convinced a team to try event sourcing. We were building odds for a betting system with lots of domain rules, projections, and audit requirements. I thought: "This is perfect for events." Spoiler: it was both one of the best and hardest decisions I've made.

Event sourcing gives you a complete, immutable log of everything that happened in your domain. Easy temporal queries. The ability to rebuild any view from scratch. But it's also a mental model shift, operationally heavier, and easy to get wrong if you treat it like "fancy CRUD."

Here's what I've learned — the patterns, the mistakes, and the practical advice — so you can decide whether and how to use event sourcing in your projects.

When Event Sourcing Makes Sense (My Checklist)

  • You need a full audit/history that's genuinely useful, not just a regulatory checkbox.
  • You have complex business rules that benefit from temporal reasoning ("what was the balance after these events?").
  • Projections and read models will frequently change and you need to rebuild them.
  • You have long-lived processes or sagas that coordinate across services.
  • You accept eventual consistency and the extra operational overhead.

When to Avoid It

  • Simple CRUD with no meaningful business history.
  • Teams unfamiliar with the model and without time to climb the learning curve.
  • When low operational complexity is more valuable than temporal power.

Core Concepts I Stress With Every Team

Events are facts. Model them as immutable, small, explicit statements. Not full snapshots of aggregate state.

Aggregates enforce invariants. Commands mutate aggregates by producing events.

The event store is the source of truth. Read models are derived and replaceable.

Expect eventual consistency. Design UX and APIs with that in mind — "processing" states, retries, idempotent consumers.

Version events from day one. You will change schemas. Plan for upcasters or versioned transforms before you need them.

A TypeScript Example to Make Things Concrete

Here's a basic event type, command, and an aggregate that produces events. Note how apply is separate from command handlers — that separation makes testing and replay reliable.

Typescript
type UUID = string;

interface Event<T = any> {
  readonly type: string;
  readonly aggregateId: UUID;
  readonly data: T;
  readonly timestamp: string;
  readonly version: number;
  readonly sequence?: number;
}

interface Command {
  readonly aggregateId: UUID;
  readonly commandId: UUID;
}

interface AccountOpened {
  readonly initialBalance: number;
}

interface MoneyDeposited {
  readonly amount: number;
}

type AccountEvent = Event<AccountOpened | MoneyDeposited>;

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

  apply(event: AccountEvent): void {
    this.version = event.sequence ?? this.version + 1;
    switch (event.type) {
      case "AccountOpened":
        this.balance = (event.data as AccountOpened).initialBalance;
        break;
      case "MoneyDeposited":
        this.balance += (event.data as MoneyDeposited).amount;
        break;
      default:
        throw new Error(`Unknown event ${event.type}`);
    }
  }

  handleDeposit(command: Command & { amount: number }): AccountEvent[] {
    if (command.amount <= 0) throw new Error("Amount must be positive");
    const evt: AccountEvent = {
      type: "MoneyDeposited",
      aggregateId: command.aggregateId,
      data: { amount: command.amount },
      timestamp: new Date().toISOString(),
      version: 1
    };
    this.apply(evt);
    return [evt];
  }
}

Event Store, Optimistic Concurrency, and Idempotency

In one project we used event store append semantics with an "expected version" to avoid lost updates. If two writers try to update an aggregate concurrently, one fails and must retry. That saved us from subtle race conditions.

Typescript
interface StoredEvent<T = any> {
  id: UUID;
  type: string;
  aggregateId: UUID;
  data: T;
  timestamp: string;
  sequence: number;
}

interface EventStore {
  readStream(aggregateId: UUID, fromSequence?: number): Promise<StoredEvent[]>;
  appendToStream(
    aggregateId: UUID,
    events: StoredEvent[],
    expectedSequence?: number
  ): Promise<void>;
}

If appendToStream detects a mismatched expectedSequence, it throws a concurrency error. The command handler re-loads events, rehydrates the aggregate, and re-applies the command.

Idempotency is another gotcha. Commands should carry a commandId. Store processed command IDs in a dedupe table or embed them in projections to avoid double effects from retries.

Projections and Rebuilding

Projections build your read models. A hard lesson I learned: rebuilds will happen. A bug in a projection, a new read model requirement — design for efficient rebuilds from the start.

Typescript
type ProjectionHandler<T> = {
  init: () => T;
  apply: (state: T, event: StoredEvent) => T;
};

async function rebuildProjection<T>(
  events: StoredEvent[],
  handler: ProjectionHandler<T>
): Promise<T> {
  let state = handler.init();
  for (const ev of events) {
    state = handler.apply(state, ev);
  }
  return state;
}

Snapshots and Performance

If your aggregates have large event streams, rehydration gets slow. I had a customer aggregate with thousands of small events. Replaying them on each request killed latency. We added snapshots — periodically store the serialized aggregate state with its last applied sequence number. On load, read the snapshot, then replay only the events that came after.

Event Versioning and Upcasters

I used to ignore versioning. Then we hit a production bug where older events didn't have a field we now required. Lesson: version every event and provide upcasters that transform old event shapes to current ones.

Typescript
function upcast(event: StoredEvent): StoredEvent {
  if (event.type === "MoneyDeposited" && (event.data as any).currency === undefined) {
    return {
      ...event,
      data: { ...(event.data as any), currency: "USD" }
    };
  }
  return event;
}

Operational Concerns I Wish Someone Told Me Earlier

  • Backups. Backing up event streams is backing up your system. Test restore procedures.
  • Metrics. Track event throughput, projection lag, last processed sequence, snapshot age.
  • Monitoring. Alert on stalled projections or high concurrency failures.
  • GDPR and retention. "Delete an event" isn't straightforward. Encrypt PII fields and drop keys, redact projections, or implement event redaction patterns. Think about this up front.
  • Replays. Have safe, idempotent tools to replay streams into projections.
  • Infrastructure choices. Kafka gives scale and partitioning but needs careful partition strategy. EventStoreDB is purpose-built with expected-version semantics. Postgres as an event store is pragmatic and transactional but needs careful indexing.

Sagas and Process Managers

For long-running processes (payment -> shipping -> invoice), use a separate process manager. It listens for events and emits commands. Keep it testable and idempotent. Don't mix long-lived external calls inside aggregate logic.

Testing Strategies

  • Unit test aggregates as pure functions. Load events, apply them, run a command, assert expected output events.
  • Integration test projections by replaying known event sequences.
  • End-to-end tests that simulate eventual consistency boundaries — assert with retry and timeouts.

Hard-Earned Truths

I once recommended event sourcing to a team that only needed audit logging. We added huge complexity and slowed delivery. I should have started simpler.

Conversely, in a fraud-detection project, event sourcing shortened debugging cycles dramatically. Replaying timelines helped us find the exact sequence that led to detection misses.

My Adoption Path

  1. Start with a clear problem that events solve (audit, rebuildable projections, complex temporal logic).
  2. Prototype a single bounded context with events, projections, and snapshotting.
  3. Build replay and rebuild tooling before you have many read models.
  4. Add monitoring, metrics, and automated tests for replays and upcasters.
  5. Only expand to additional domains once patterns are stable.

Green-Light Checklist

  • Team familiarity or time to learn
  • Event store choice and backing infrastructure
  • Concurrency and idempotency strategy
  • Snapshot and retention policy
  • Projection rebuild tooling and monitoring
  • Strategy for event schema evolution and GDPR

Event sourcing feels magical when it fits. It feels painful when it's the wrong tool. I've been burned by reaching for it too early and rewarded when temporal correctness mattered. Start small. Build tooling first. Expect to iterate on event schemas and operational practices.