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. This provides 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)
  • Examples: banking ledgers, inventory systems, order tracking, collaborative editing

Solution

Python

from dataclasses import dataclass, asdict
from typing import List, Dict, Callable
from datetime import datetime
import json

@dataclass
class Event:
    type: str
    entity_id: str
    payload: dict
    timestamp: 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.
  • Entity Rehydration: Rebuilding current state by replaying all events for an entity
  • Projections: Creating read-optimized views by subscribing to the event stream
  • Snapshots: Periodically saving the computed state to avoid replaying thousands of events

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
  • Idempotent consumers — the same event should be safe to process multiple times
  • 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

Troubleshooting

  • Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
  • Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
  • Tight coupling after refactoring: check that interfaces are stable and dependencies point inward.
  • Tests break when the design changes: favor stable contracts over internal structure.
  • Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.

Key Takeaways

  • Apply event sourcing pattern when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Common Production Pitfalls

  • Applying the pattern where no abstraction is needed, adding accidental complexity.
  • Letting the pattern leak into unrelated modules and blur ownership boundaries.
  • Over-engineering the first implementation instead of starting simple and measuring pain.
  • Skipping contract tests, so refactors silently break consumers.
  • Ignoring failure modes that the pattern does not cover.
  • Using the pattern as a default instead of choosing the right tool for the current scale.
  • Forgetting to document when to stop using the pattern and what replaces it.
  • Missing observability around the pattern’s performance and error propagation.

Frequently Asked Questions

How do I handle schema changes in events?

Version your event types (OrderPlaced_v1, OrderPlaced_v2). During replay, use an upcaster that transforms old events into the current schema before applying them.

Can I delete data under GDPR with Event Sourcing?

You cannot delete events, but you can encrypt them with a user-specific key and delete that key. Alternatively, append a DataForgotten event and filter 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 events after it. This keeps replay time constant. See CQRS for read model patterns that work well with Event Sourcing.