Backend Engineering: Introduction to Event Sourcing
Event sourcing keeps coming up in system design interviews, and for good reason — it solves real problems that CRUD-based systems struggle with. If you've ever needed a full audit trail, wanted to…
Backend Engineering: Introduction to Event Sourcing
Event sourcing keeps coming up in system design interviews, and for good reason — it solves real problems that CRUD-based systems struggle with. If you've ever needed a full audit trail, wanted to replay history to debug a production issue, or tried to sync multiple services without losing data, event sourcing is worth understanding deeply.
Why Event Sourcing Matters
Traditional applications store *current state*. You have a users table, and when someone updates their email, you overwrite the old value. Done. Simple. But you've just lost information — you no longer know what the email *was*, when it changed, or why.
Event sourcing flips this model. Instead of storing the current state of an entity, you store a sequence of events that led to that state. The current state becomes a derived value — something you compute by replaying those events.
This matters for several reasons:
How Event Sourcing Works
The core idea: never delete or update records, only append new events.
Let's say you're building a bank account system. In a traditional system, you'd have:
accounts (id, owner, balance, updated_at)In event sourcing, you'd have an event store:
events (id, aggregate_id, event_type, payload, created_at)And your events might look like this:
{ "event_type": "AccountOpened", "aggregate_id": "acc_123", "payload": { "owner": "Alice", "initial_balance": 0 } }
{ "event_type": "MoneyDeposited", "aggregate_id": "acc_123", "payload": { "amount": 500 } }
{ "event_type": "MoneyWithdrawn", "aggregate_id": "acc_123", "payload": { "amount": 200 } }To get the current balance, you replay these events in order. The final state is 500 - 200 = 300.
Here's a simplified Python example of how this looks in code:
from dataclasses import dataclass, field
from typing import List@dataclass
class AccountEvent:
event_type: str
payload: dict
@dataclass
class BankAccount:
account_id: str
owner: str = ""
balance: float = 0.0
events: List[AccountEvent] = field(default_factory=list)
def apply(self, event: AccountEvent):
if event.event_type == "AccountOpened":
self.owner = event.payload["owner"]
self.balance = event.payload["initial_balance"]
elif event.event_type == "MoneyDeposited":
self.balance += event.payload["amount"]
elif event.event_type == "MoneyWithdrawn":
if event.payload["amount"] > self.balance:
raise ValueError("Insufficient funds")
self.balance -= event.payload["amount"]
def deposit(self, amount: float):
event = AccountEvent("MoneyDeposited", {"amount": amount})
self.apply(event)
self.events.append(event) # Persist this
def withdraw(self, amount: float):
event = AccountEvent("MoneyWithdrawn", {"amount": amount})
self.apply(event)
self.events.append(event) # Persist this
Reconstruct from stored events
def load_account(account_id: str, stored_events: List[AccountEvent]) -> BankAccount:
account = BankAccount(account_id=account_id)
for event in stored_events:
account.apply(event)
return accountThe key pattern here: commands produce events, events mutate state. The deposit method doesn't directly change the balance — it creates an event, applies it, and queues it for persistence.
The CQRS Connection
Event sourcing almost always travels with CQRS (Command Query Responsibility Segregation). The idea is simple: separate the write model (commands) from the read model (queries).
Your event store is optimized for writes — it's an append-only log. But querying it directly (e.g., "give me all accounts with balance > $1000") would be painfully slow. So you maintain separate projections — read-optimized views built by consuming your event stream.
# A simple projection that maintains a balance summary table
def project_account_summary(event: AccountEvent, read_db):
if event.event_type == "AccountOpened":
read_db.insert("account_summary", {
"account_id": event.aggregate_id,
"owner": event.payload["owner"],
"balance": event.payload["initial_balance"]
})
elif event.event_type == "MoneyDeposited":
read_db.increment("account_summary", event.aggregate_id, "balance", event.payload["amount"])
elif event.event_type == "MoneyWithdrawn":
read_db.decrement("account_summary", event.aggregate_id, "balance", event.payload["amount"])If you ever need a new view of your data — say, a fraud detection report — you just write a new projection and replay all historical events through it. That's genuinely powerful.
The Drawbacks (Be Honest About These)
Event sourcing isn't a silver bullet. Here's what you're signing up for:
Eventual consistency. Your projections lag behind your event store. A user might deposit money and not see the updated balance immediately. This is often acceptable, but your team needs to understand it.
Complexity. You're adding infrastructure — an event store, projection workers, potentially a message broker. Simple CRUD apps don't need this overhead.
Schema evolution is hard. Events are immutable. If you stored an event with a bug or a field name you later regret, you can't just run an ALTER TABLE. You need versioning strategies — upcasting old events, versioned event types, or migration events.
Snapshots become necessary. If an account has 10,000 events, replaying all of them on every request is slow. You'll need to periodically snapshot the current state and only replay events after the snapshot.
def load_account_with_snapshot(account_id: str, snapshot_store, event_store) -> BankAccount:
snapshot = snapshot_store.get_latest(account_id)
if snapshot:
account = snapshot.state # Pre-built state
events_after = event_store.get_events_after(account_id, snapshot.version)
else:
account = BankAccount(account_id=account_id)
events_after = event_store.get_all_events(account_id) for event in events_after:
account.apply(event)
return account
Practical Tips for Getting Started
Start with a bounded context that truly benefits. Don't rewrite your entire system. Find a domain where audit history, temporal queries, or event-driven integration are genuinely valuable — order management, financial transactions, inventory changes.
Pick the right event store. [EventStoreDB](https://www.eventstore.com/) is purpose-built for this. Kafka works well if you're already using it. PostgreSQL with an append-only events table works fine for smaller scale — don't over-engineer early.
Name events in past tense. OrderPlaced, PaymentFailed, ItemShipped. Events describe things that *happened*, not commands or states.
Version your events from day one. Add a schema_version field to every event payload. Future you will be grateful.
Don't expose your event store directly to consumers. Publish events to a message bus (Kafka, RabbitMQ, SNS) and let downstream services build their own projections. This keeps your write model clean.
Next Steps
Here's how to go from reading to understanding:
apply() methods and projections will cement the concepts faster than any article.Event sourcing is one of those patterns that feels complex until it clicks — and then you start seeing exactly where it fits (and where it doesn't). Get your hands dirty with a small implementation and you'll be ahead of most candidates who only know the theory.