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.
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:
| Aspect | Event Sourcing | CDC |
|---|---|---|
| Source of truth | Event log | Relational tables |
| Event semantics | Domain events (meaningful) | Row-level changes (technical) |
| Schema | Designed by developers | Mirrors DB schema |
| Replay | Full rebuild possible | Depends 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.
Related articles
- System Design Backpressure Strategies in Distributed Systems
Learn how backpressure prevents overload in distributed systems. Covers load shedding, rate limiting, buffering, and flow control patterns with examples.
- System Design Consistent Hashing Deep Dive: Virtual Nodes and Rebalancing
Go beyond basic consistent hashing. Learn how virtual nodes solve imbalance, how rebalancing works during scale events, and real-world usage in Cassandra and DynamoDB.
- System Design CQRS Explained: Command Query Responsibility Segregation
Learn how CQRS separates read and write models for better scalability. Covers architecture, implementation patterns, and when CQRS is worth the complexity.
- System Design Idempotency Patterns: Keys, Deduplication, and Exactly-Once Semantics
Learn how to design idempotent APIs and achieve exactly-once semantics using idempotency keys, deduplication stores, and transactional outbox patterns.