Skip to content
Codeloom
System Design

Event Sourcing Explained: Architecture and Trade-offs

Understand event sourcing fundamentals, how it differs from CRUD, when to use it, and the real-world trade-offs you need to consider before adopting it.

·7 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How event sourcing differs from traditional CRUD storage
  • Build an event store and reconstruct state from events
  • Understand snapshots, projections, and replay mechanics
  • Evaluate the trade-offs before adopting event sourcing

Prerequisites

  • Basic understanding of databases and REST APIs
  • Familiarity with domain-driven design concepts

In a traditional CRUD application, you store the current state of an entity. When a user updates their shipping address, you overwrite the old address with the new one. The history is gone. Event sourcing flips this model: instead of storing current state, you store every change as an immutable event. The current state is derived by replaying those events from the beginning.

The Core Idea

Consider a bank account. In a CRUD system, the database stores a single row with the current balance. In an event-sourced system, the database stores every transaction:

CRUD approach:
  accounts table: { id: 123, balance: 750.00 }

Event sourcing approach:
  events table:
    1. AccountOpened    { id: 123, initial_deposit: 1000.00 }
    2. MoneyWithdrawn   { id: 123, amount: 200.00 }
    3. MoneyDeposited   { id: 123, amount: 50.00 }
    4. MoneyWithdrawn   { id: 123, amount: 100.00 }

  Current balance = 1000 - 200 + 50 - 100 = 750.00

The event log is the source of truth. The balance is a derived value.

Event Store Structure

An event store is an append-only log. Events are never updated or deleted. A minimal schema looks like this:

CREATE TABLE events (
    event_id       BIGSERIAL PRIMARY KEY,
    aggregate_type VARCHAR(100) NOT NULL,
    aggregate_id   UUID NOT NULL,
    event_type     VARCHAR(100) NOT NULL,
    event_data     JSONB NOT NULL,
    metadata       JSONB,
    version        INTEGER NOT NULL,
    created_at     TIMESTAMPTZ DEFAULT NOW(),
    UNIQUE (aggregate_id, version)
);

The version column enforces optimistic concurrency. When two commands try to append events to the same aggregate simultaneously, only one succeeds because the unique constraint on (aggregate_id, version) prevents conflicts.

Reconstructing State

To get the current state of an aggregate, you load all its events and replay them:

class BankAccount:
    def __init__(self):
        self.balance = 0
        self.status = "new"

    def apply(self, event):
        match event["event_type"]:
            case "AccountOpened":
                self.balance = event["data"]["initial_deposit"]
                self.status = "active"
            case "MoneyDeposited":
                self.balance += event["data"]["amount"]
            case "MoneyWithdrawn":
                self.balance -= event["data"]["amount"]
            case "AccountClosed":
                self.status = "closed"

def load_account(account_id, event_store):
    account = BankAccount()
    events = event_store.get_events(aggregate_id=account_id)
    for event in events:
        account.apply(event)
    return account

This pattern separates the decision logic (commands) from the state mutation logic (event application). Commands validate business rules and emit events. Events are then applied to update state.

The Command Flow

┌──────────┐     ┌───────────────┐     ┌─────────────┐
│ API call │────>│ Command       │────>│ Event Store │
│ withdraw │     │ Handler       │     │ (append)    │
│ $200     │     │               │     └──────┬──────┘
└──────────┘     │ 1. Load events│            │
                 │ 2. Rebuild    │            │
                 │    state      │     ┌──────┴──────┐
                 │ 3. Validate   │     │ Event Bus   │
                 │    (balance   │     │ (publish)   │
                 │    >= 200?)   │     └──────┬──────┘
                 │ 4. Emit event │            │
                 └───────────────┘     ┌──────┴──────┐
                                       │ Projections │
                                       │ (read models)│
                                       └─────────────┘

Snapshots

Replaying hundreds of thousands of events for every command is impractical. Snapshots solve this by periodically saving the current state alongside the event stream.

Events: [1] [2] [3] ... [999] [1000] [1001] [1002]
                                 ^
                           Snapshot at v1000:
                           { balance: 45230.00, status: "active" }

To reconstruct at v1002:
  1. Load snapshot at v1000
  2. Replay events 1001 and 1002
  Result: only 2 events to replay instead of 1002

A common strategy is to create a snapshot every N events (for example, every 100) or when the replay time exceeds a threshold.

Projections (Read Models)

Event sourcing stores data optimized for writes (append-only log), but reads often need different shapes: tables, search indexes, or materialized views. Projections solve this.

A projection subscribes to the event stream and builds a read-optimized view:

class AccountBalanceProjection:
    """Maintains a simple lookup table of account balances."""

    def __init__(self, read_db):
        self.read_db = read_db

    def handle(self, event):
        match event["event_type"]:
            case "AccountOpened":
                self.read_db.insert("balances", {
                    "account_id": event["aggregate_id"],
                    "balance": event["data"]["initial_deposit"],
                    "updated_at": event["created_at"]
                })
            case "MoneyDeposited":
                self.read_db.increment("balances",
                    account_id=event["aggregate_id"],
                    amount=event["data"]["amount"])
            case "MoneyWithdrawn":
                self.read_db.decrement("balances",
                    account_id=event["aggregate_id"],
                    amount=event["data"]["amount"])

Multiple projections can consume the same event stream to build different views: one for account balances, another for transaction history, a third for fraud detection analytics.

Event Versioning and Schema Evolution

Events are immutable, but your domain model evolves. When the shape of an event changes, you have several strategies:

Upcasting: transform old event formats to new ones at read time.

def upcast(event):
    if event["event_type"] == "MoneyWithdrawn" and "currency" not in event["data"]:
        # Old events didn't have currency; default to USD
        event["data"]["currency"] = "USD"
    return event

New event types: introduce MoneyWithdrawnV2 alongside the old MoneyWithdrawn. The aggregate’s apply method handles both.

Copy-and-transform: migrate the entire event store to a new schema. This is expensive and generally a last resort.

When Event Sourcing Shines

Event sourcing is not a universal pattern. It fits specific scenarios well:

  • Audit requirements: financial systems, healthcare records, and compliance-heavy domains where you must prove what happened and when.
  • Temporal queries: “What was the account balance on March 15?” is trivial with event sourcing and nearly impossible with CRUD.
  • Complex domain logic: when business rules depend on the history of changes, not just the current state.
  • Event-driven architectures: when downstream systems already consume events, storing events as the source of truth removes the dual-write problem.

The Trade-offs

Complexity

Event sourcing adds significant complexity. You need an event store, a projection mechanism, snapshot management, and event versioning. A simple CRUD service that took a week to build might take a month with event sourcing.

Eventual consistency

Projections are updated asynchronously. The read model might lag behind the write model by milliseconds or seconds. Users might submit a command and then not see the result immediately in a query. You need to design your UX around this.

Event store growth

The event store grows forever. A high-throughput system might generate millions of events per day. You need archival strategies, and queries across the entire event store can be expensive.

Difficulty of deletion

GDPR “right to be forgotten” is at odds with an immutable event log. Strategies include crypto-shredding (encrypting personal data with a per-user key and deleting the key) or replacing personal data with tombstone markers.

Before deletion:
  { type: "UserRegistered", data: { name: "Alice", email: "alice@example.com" } }

After crypto-shredding (key deleted):
  { type: "UserRegistered", data: { name: "[ENCRYPTED]", email: "[ENCRYPTED]" } }

Event Sourcing vs Change Data Capture

Change data capture (CDC) also produces a stream of changes, but from a CRUD database. The difference is intent:

AspectEvent SourcingCDC
Source of truthEvent logRelational tables
Event semanticsDomain events (meaningful)Row-level changes (technical)
SchemaDesigned by developersMirrors DB schema
ReplayFull rebuild possibleDepends on retention

CDC is simpler to adopt because you keep your existing CRUD database. Event sourcing gives richer domain semantics but requires rebuilding your persistence layer.

Key Takeaways

Event sourcing replaces mutable state with an immutable log of domain events. It gives you a complete audit trail, the ability to reconstruct state at any point in time, and natural integration with event-driven architectures. But it comes with real costs: increased complexity, eventual consistency in read models, and challenges around data deletion. Use it when the audit trail and temporal queries are genuine requirements, not as a default architecture for every service.