StackPractices
advanced By Mathias Paulenko

Event Sourcing Pattern

Store the state of an application as a sequence of events rather than storing only the current state. An architectural pattern for audit-friendly systems.

Topics: design

Overview

The Event Sourcing Pattern stores the state of an application as a sequence of events rather than storing only the current state. Instead of updating a record in place, you append an event describing what happened. The current state is derived by replaying all events for an entity, which gives you a complete audit trail, temporal queries, and the ability to rebuild state at any point in time.

When to Use

Use the Event Sourcing Pattern when:

  • You need a complete audit trail of every state change (finance, healthcare, compliance)
  • You want to reconstruct historical states or debug by replaying events
  • Event-driven architectures already exist, making event stores a natural fit
  • CQRS is in use, and read models can be built from event projections
  • You need to compensate for failures by replaying or reversing events (see Saga)

Typical fits: banking ledgers, inventory systems, order tracking, collaborative editing.

Skip it for plain CRUD. Event sourcing adds schema versioning, projections, and replay machinery that a small app never earns back — if nobody will ever ask “what happened and when”, a normal table is cheaper.

Solution

Python

from dataclasses import dataclass
from typing import Dict, List, Optional
from datetime import datetime

@dataclass
class Event:
    type: str
    entity_id: str
    payload: dict
    timestamp: Optional[str] = None

    def __post_init__(self):
        if self.timestamp is None:
            self.timestamp = datetime.now().isoformat()

class EventStore:
    def __init__(self):
        self.streams: Dict[str, List[Event]] = {}

    def append(self, entity_id: str, event: Event):
        self.streams.setdefault(entity_id, []).append(event)

    def get_stream(self, entity_id: str) -> List[Event]:
        return list(self.streams.get(entity_id, []))

# Entity: rebuild state from events
class BankAccount:
    def __init__(self, account_id: str):
        self.account_id = account_id
        self.balance = 0
        self.version = 0

    def apply(self, event: Event):
        if event.type == "Deposited":
            self.balance += event.payload["amount"]
        elif event.type == "Withdrawn":
            self.balance -= event.payload["amount"]
        self.version += 1

    @classmethod
    def rehydrate(cls, account_id: str, events: List[Event]):
        account = cls(account_id)
        for e in events:
            account.apply(e)
        return account

# Usage
store = EventStore()
account_id = "ACC-123"

store.append(account_id, Event("Deposited", account_id, {"amount": 100}))
store.append(account_id, Event("Withdrawn", account_id, {"amount": 30}))
store.append(account_id, Event("Deposited", account_id, {"amount": 50}))

# Rebuild state
account = BankAccount.rehydrate(account_id, store.get_stream(account_id))
print(f"Balance: {account.balance}")  # 120

# Full audit trail
for e in store.get_stream(account_id):
    print(f"{e.timestamp}: {e.type} {e.payload}")

JavaScript

class Event {
  constructor(type, entityId, payload) {
    this.type = type;
    this.entityId = entityId;
    this.payload = payload;
    this.timestamp = new Date().toISOString();
  }
}

class EventStore {
  constructor() {
    this.streams = new Map();
  }

  append(entityId, event) {
    if (!this.streams.has(entityId)) this.streams.set(entityId, []);
    this.streams.get(entityId).push(event);
  }

  getStream(entityId) {
    return this.streams.get(entityId) || [];
  }
}

class BankAccount {
  constructor(accountId) {
    this.accountId = accountId;
    this.balance = 0;
    this.version = 0;
  }

  apply(event) {
    if (event.type === "Deposited") this.balance += event.payload.amount;
    if (event.type === "Withdrawn") this.balance -= event.payload.amount;
    this.version++;
  }

  static rehydrate(accountId, events) {
    const account = new BankAccount(accountId);
    events.forEach(e => account.apply(e));
    return account;
  }
}

// Usage
const store = new EventStore();
const accountId = "ACC-123";

store.append(accountId, new Event("Deposited", accountId, { amount: 100 }));
store.append(accountId, new Event("Withdrawn", accountId, { amount: 30 }));
store.append(accountId, new Event("Deposited", accountId, { amount: 50 }));

const account = BankAccount.rehydrate(accountId, store.getStream(accountId));
console.log("Balance:", account.balance); // 120

// Audit trail
store.getStream(accountId).forEach(e =>
  console.log(`${e.timestamp}: ${e.type}`, e.payload)
);

Java

import java.util.*;

class Event {
    String type;
    String entityId;
    Map<String, Object> payload;
    String timestamp;

    Event(String type, String entityId, Map<String, Object> payload) {
        this.type = type;
        this.entityId = entityId;
        this.payload = payload;
        this.timestamp = new Date().toString();
    }
}

class EventStore {
    private final Map<String, List<Event>> streams = new HashMap<>();

    void append(String entityId, Event event) {
        streams.computeIfAbsent(entityId, k -> new ArrayList<>()).add(event);
    }

    List<Event> getStream(String entityId) {
        return new ArrayList<>(streams.getOrDefault(entityId, List.of()));
    }
}

class BankAccount {
    String accountId;
    double balance = 0;
    int version = 0;

    BankAccount(String accountId) {
        this.accountId = accountId;
    }

    void apply(Event event) {
        switch (event.type) {
            case "Deposited" -> balance += (double) event.payload.get("amount");
            case "Withdrawn" -> balance -= (double) event.payload.get("amount");
        }
        version++;
    }

    static BankAccount rehydrate(String accountId, List<Event> events) {
        BankAccount account = new BankAccount(accountId);
        events.forEach(account::apply);
        return account;
    }
}

// Usage
EventStore store = new EventStore();
String accountId = "ACC-123";

store.append(accountId, new Event("Deposited", accountId, Map.of("amount", 100.0)));
store.append(accountId, new Event("Withdrawn", accountId, Map.of("amount", 30.0)));
store.append(accountId, new Event("Deposited", accountId, Map.of("amount", 50.0)));

BankAccount account = BankAccount.rehydrate(accountId, store.getStream(accountId));
System.out.println("Balance: " + account.balance); // 120.0

Explanation

Event Sourcing replaces the traditional CRUD model with an append-only event log:

  • Event Store: append-only log of all domain events per entity
  • Events: immutable records describing what happened (e.g., MoneyDeposited, OrderShipped)
  • Entity Rehydration: rebuilding current state by replaying all events for an entity
  • Projections: read-optimized views built by subscribing to the event stream
  • Snapshots: the computed state saved periodically so you don’t replay thousands of events
flowchart diagram: CMD[Command

The trade-offs matter as much as the mechanics:

  • Replay cost grows with stream length. Rehydrating an entity is O(number of events), so long-lived entities need snapshots or reads become your latency bottleneck.
  • Schema changes are forever. Renaming a field breaks replay of old events unless you add an upcaster or keep the legacy branch in apply. Version event types from day one.
  • Reads are eventually consistent. A projection can lag behind the write side; a read right after a write may return stale data unless you track versions or wait on the consumer.
  • The log only grows. Storage cost, retention policy, and GDPR erasure are design decisions, not afterthoughts.

Variants

VariantDescriptionUse Case
Full Event SourcingAll state from events; no mutable DBMaximum auditability; compliance
HybridEvents + current state snapshotPerformance; reduce replay cost
Temporal QueriesQuery state at any point in timeDebugging; historical reporting
Event ReplayReplay events to rebuild or migrateSchema migrations; bug recovery

What Works

  • Events should describe business intent (e.g., OrderPlaced) not technical actions (RowInserted)
  • Never delete or mutate events — the log is immutable
  • Use snapshots for long-lived entities to avoid replaying thousands of events
  • Version your event schemas for forward/backward compatibility
  • Make consumers idempotent — the same event must be safe to process more than once
  • Encrypt sensitive payload fields at the application level

Common Mistakes

  • Using events as a message bus instead of a state store (separate concerns)
  • Mutating or deleting events, breaking the audit trail
  • Forgetting to handle event schema evolution (breaking old replays)
  • Replaying all events from the beginning of time without snapshots
  • Storing large binary payloads inside events instead of references
  • Not handling duplicate event delivery in distributed systems

Production Notes

  • Roll out event schema changes in two steps: deploy code that reads both the old and new version first, then start emitting the new version. Never break replay of historical streams.
  • Alert on projection lag, not only on errors: a consumer falling behind the write side looks like data loss to users.
  • Set a snapshot cadence per entity type (e.g., every 50–100 events) and measure rehydration time in staging with production-like stream lengths.
  • Budget for storage growth: event stores only grow. Decide retention, archival, and payload size limits up front.
  • Encrypt personal data per subject: crypto-shredding (deleting the subject’s key) is the practical answer to GDPR erasure when history can’t be rewritten.

Troubleshooting

  • Replay produces wrong state: an old event schema no longer matches the current apply logic. Add an upcaster for that version or keep the legacy branch in apply.
  • Read model returns stale data: the projection consumer fell behind the event store. Check consumer lag and have writes return the applied version so the UI can wait or refetch.
  • Duplicate events in the stream: the producer retried after a store-side timeout. Make appends idempotent with a unique event ID or an expected-version check on write.
  • Rehydration slows down over time: stream length outgrew the snapshot cadence. Shorten the snapshot interval or page through the stream.
  • Personal data landed in event payloads: events can’t be deleted. Rotate the data-subject encryption key or append a DataForgotten marker event and honor it in projections.

Key Takeaways

  • State is a fold over events: don’t store “the row”, store what happened and derive the rest.
  • The event log is immutable and versioned — schema evolution is a day-one design constraint.
  • Add snapshots when replay cost hurts, not by default.
  • Event Sourcing isn’t a read model; pair it with CQRS when read and write workloads diverge.

Further Reading

Frequently Asked Questions

How do I handle schema changes in events?

Version your event types (OrderPlaced_v1, OrderPlaced_v2) and keep an upcaster that transforms old events into the current schema during replay.

Can I delete data under GDPR with Event Sourcing?

You can't delete events, but you can encrypt personal fields with a per-subject key and delete that key — crypto-shredding. Alternatively, append a DataForgotten event and honor it in projections.

How do snapshots work?

After every N events, save the computed entity state. On rehydration, load the latest snapshot and replay only the events after it, which keeps replay time roughly constant.

Is Event Sourcing the same as event-driven architecture?

No. Event-driven systems publish events to notify other services; Event Sourcing uses events as the source of truth for state. They combine well but either can exist alone.

Does Event Sourcing require CQRS?

Not strictly, but they pair naturally: the event store is the write side and projections build the read side. Without CQRS you still need a way to serve queries without replaying on every request.

How do I stop concurrent writers from corrupting a stream?

Use optimistic concurrency: append with an expected version and let the store reject the write if the stream moved on. The caller reloads the stream and retries.