- Event sourcing: instead of storing current state (balance = ₹4,200), store the immutable sequence of events that produced it (Opened, Deposited 5000, Withdrew 800). Current state = replay.
- CQRS: separate the write model (validates commands, emits events) from read models (denormalised views built for specific queries). Often paired with event sourcing, but independent.
- An event store needs per-stream ordering and an expected-version check on append; that check is your optimistic concurrency control.
- Projections turn events into read models and can be rebuilt from scratch; snapshots bound aggregate load time.
- Hard parts: event versioning, eventual consistency of reads, GDPR deletion, tooling. Do not use it for plain CRUD.
Your bank does not store your balance as a single number it overwrites. It keeps a ledger of every transaction, and the balance is a consequence of that ledger. Event sourcing applies the same idea to software: the history is the source of truth, and everything else is derived. CQRS (Command Query Responsibility Segregation) is the natural companion: once state is derived, you can derive it in several shapes, each optimised for a different query. This guide builds both from scratch with a bank account example, then covers what changes when you run it in production.
State-Oriented vs Event-Sourced Persistence
In a typical CRUD system, a withdrawal looks like this:
UPDATE accounts SET balance = balance - 800, updated_at = now() WHERE id = 'ACC-1';
After it runs, the previous balance is gone. You know the account has ₹4,200, but not how it got there, unless you also wrote an audit log, and the audit log might disagree with the table because nothing forces them to stay in sync.
In an event-sourced system, the same withdrawal is an append:
INSERT INTO events (stream_id, version, type, data)
VALUES ('ACC-1', 3, 'MoneyWithdrawn', '{"amount": 800, "atm": "HDFC-BLR-221"}');
The account's state is computed from all its events:
| Version | Event | Balance after |
|---|---|---|
| 1 | AccountOpened { owner: "Asha" } | 0 |
| 2 | MoneyDeposited { amount: 5000 } | 5,000 |
| 3 | MoneyWithdrawn { amount: 800 } | 4,200 |
What you gain
- Complete audit trail by construction. The log is not a side effect that can drift; it is the data. Valuable in finance, healthcare, compliance-heavy domains.
- Temporal queries. "What was this account's balance on 31 March?" is a replay up to that date.
- New read models from old data. Need a fraud dashboard next quarter? Build a new projection and replay all history into it.
- Debugging. Reproduce a bug by replaying the exact sequence of events that triggered it.
- Natural integration. Events are already the messages other services want.
What you pay
- Querying current state requires projections (eventual consistency for reads).
- Events are a permanent contract; changing their shape is hard.
- More moving parts: event store, projectors, snapshots, replay tooling.
- A learning curve for the whole team.
Event Sourcing a Bank Account (Java and Python)
The core building block is the aggregate: a consistency boundary (one bank account) that validates commands against its current state and emits events. Its state is rebuilt by applying events in order. The key discipline: commands can be rejected; events cannot. An event records something that already happened, so applying it must never fail.
import java.util.ArrayList;
import java.util.List;
sealed interface AccountEvent permits AccountOpened, MoneyDeposited, MoneyWithdrawn {}
record AccountOpened(String accountId, String owner) implements AccountEvent {}
record MoneyDeposited(long amountPaise) implements AccountEvent {}
record MoneyWithdrawn(long amountPaise) implements AccountEvent {}
final class BankAccount {
private String id;
private long balancePaise;
private boolean open;
private long version; // number of events applied
private final List<AccountEvent> pending = new ArrayList<>();
// Rebuild from history
static BankAccount fromHistory(List<AccountEvent> history) {
BankAccount a = new BankAccount();
for (AccountEvent e : history) { a.apply(e); a.version++; }
return a;
}
// ---- command handlers: validate, then record events ----
static BankAccount open(String id, String owner) {
BankAccount a = new BankAccount();
a.record(new AccountOpened(id, owner));
return a;
}
void deposit(long amountPaise) {
requireOpen();
if (amountPaise <= 0) throw new IllegalArgumentException("amount must be positive");
record(new MoneyDeposited(amountPaise));
}
void withdraw(long amountPaise) {
requireOpen();
if (amountPaise <= 0) throw new IllegalArgumentException("amount must be positive");
if (amountPaise > balancePaise) throw new IllegalStateException("insufficient funds");
record(new MoneyWithdrawn(amountPaise));
}
// ---- state transitions: never throw ----
private void apply(AccountEvent e) {
switch (e) {
case AccountOpened o -> { id = o.accountId(); open = true; }
case MoneyDeposited d -> balancePaise += d.amountPaise();
case MoneyWithdrawn w -> balancePaise -= w.amountPaise();
}
}
private void record(AccountEvent e) { apply(e); pending.add(e); }
private void requireOpen() { if (!open) throw new IllegalStateException("account not open"); }
List<AccountEvent> pendingEvents() { return List.copyOf(pending); }
long loadedVersion() { return version; } // expected version for the append
long balancePaise() { return balancePaise; }
String id() { return id; }
}
(Pattern matching for switch on a sealed interface requires Java 21.) Money is stored in paise as a long to avoid floating-point errors.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class AccountOpened:
account_id: str
owner: str
@dataclass(frozen=True)
class MoneyDeposited:
amount: int # paise
@dataclass(frozen=True)
class MoneyWithdrawn:
amount: int
@dataclass
class BankAccount:
id: str = ""
balance: int = 0
is_open: bool = False
version: int = 0
pending: list = field(default_factory=list)
@classmethod
def from_history(cls, events):
acc = cls()
for e in events:
acc._apply(e)
acc.version += 1
return acc
@classmethod
def open(cls, account_id: str, owner: str):
acc = cls()
acc._record(AccountOpened(account_id, owner))
return acc
def deposit(self, amount: int):
self._require_open()
if amount <= 0:
raise ValueError("amount must be positive")
self._record(MoneyDeposited(amount))
def withdraw(self, amount: int):
self._require_open()
if amount <= 0:
raise ValueError("amount must be positive")
if amount > self.balance:
raise ValueError("insufficient funds")
self._record(MoneyWithdrawn(amount))
def _apply(self, e):
match e:
case AccountOpened(account_id=aid):
self.id, self.is_open = aid, True
case MoneyDeposited(amount=a):
self.balance += a
case MoneyWithdrawn(amount=a):
self.balance -= a
def _record(self, e):
self._apply(e)
self.pending.append(e)
def _require_open(self):
if not self.is_open:
raise ValueError("account not open")
Dry run
- Load. Event store returns [AccountOpened, MoneyDeposited(500000)] for ACC-1. Replay: open = true, balance = 5,00,000 paise (₹5,000), version = 2.
- Command.
withdraw(80000). Checks pass (80,000 ≤ 5,00,000). Records MoneyWithdrawn(80000); in-memory balance = 4,20,000. - Save. Append pending events to stream ACC-1 expecting version 2. Succeeds; stream is now at version 3.
- Concurrent request. Another request also loaded at version 2 and tries to withdraw ₹4,500. It passes its in-memory check (it saw ₹5,000), but its append expecting version 2 fails because the stream is at 3. It reloads, sees ₹4,200, and rejects the withdrawal. No overdraft.
Step 4 is the most important line in event sourcing: the expected version check on append replaces row locks. It is optimistic concurrency, exactly like the version-column pattern in our ACID and isolation guide.
Designing the Event Store
An event store needs to do three things well: append events to a stream atomically with a version check, read one stream in order, and read all events globally in order (for projections). A relational database does this fine at moderate scale.
CREATE TABLE events (
global_position BIGSERIAL PRIMARY KEY, -- total order for projections
stream_id TEXT NOT NULL, -- e.g. 'account-ACC-1'
version INT NOT NULL, -- 1, 2, 3 ... per stream
type TEXT NOT NULL,
data JSONB NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}', -- correlation id, user, schema version
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (stream_id, version) -- optimistic concurrency
);
import json
from psycopg import errors
class ConcurrencyError(Exception):
pass
def append(conn, stream_id: str, expected_version: int, events: list[tuple[str, dict]]):
"""events: list of (type, data). Raises ConcurrencyError on version conflict."""
try:
with conn.transaction():
for i, (etype, data) in enumerate(events, start=1):
conn.execute(
"INSERT INTO events (stream_id, version, type, data) VALUES (%s, %s, %s, %s)",
(stream_id, expected_version + i, etype, json.dumps(data)))
except errors.UniqueViolation:
raise ConcurrencyError(f"{stream_id} moved past version {expected_version}")
def load(conn, stream_id: str):
return conn.execute(
"SELECT version, type, data FROM events WHERE stream_id = %s ORDER BY version",
(stream_id,)).fetchall()
The UNIQUE (stream_id, version) constraint does the concurrency check: if another writer already inserted version 3, our insert of version 3 fails and the whole batch rolls back.
The global ordering gotcha
Projections usually read "all events after position X". With a BIGSERIAL, sequence values are assigned at insert time but transactions commit in a different order, so a projector can see position 101 committed before 100 and skip 100 forever. Fixes: have projectors re-read a small trailing window, track gaps and wait briefly for them to fill, serialise appends through a lock, or use the database's commit-ordered log via CDC. Dedicated stores like EventStoreDB (now KurrentDB) and frameworks like Axon or Marten handle this for you.
Event store options
| Option | Notes |
|---|---|
| Postgres/MySQL table | Simple, transactional, familiar ops. Fine for most systems. Marten (.NET) builds on Postgres. |
| EventStoreDB / KurrentDB | Purpose-built: streams, expected-version appends, subscriptions, projections. |
| Axon Server | Event store + message routing for the Axon Framework (Java). |
| DynamoDB | Partition key = stream ID, sort key = version, conditional writes for concurrency; DynamoDB Streams for projections. |
| Kafka | Great as the distribution log to other services; weak as the primary store (no per-key expected-version append, slow per-aggregate reads). |
CQRS: Separating Commands from Queries
CQRS, a term popularised by Greg Young building on Bertrand Meyer's command-query separation, says: the model you use to change data does not have to be the model you use to read it.
Why separate them? Write models are shaped by invariants ("never overdraw"), which favour small, consistent aggregates. Read models are shaped by screens and reports ("monthly statement with merchant names and categories"), which favour denormalised, pre-joined data. One model serving both ends up compromised for each. Reads also typically outnumber writes heavily, so scaling them independently helps.
CQRS without event sourcing
CQRS does not require event sourcing. A simple form: write to normalised Postgres tables, maintain a denormalised read table via triggers, materialized views, or CDC into Elasticsearch. This is often the right first step: you get fast tailored reads without changing how writes are stored. Our sharding guide uses exactly this to serve a restaurant-side view from a customer-sharded orders table.
The consistency consequence
Read models update asynchronously, typically within milliseconds to a second, but not instantly. After a user withdraws money, the statement page might briefly not show it. Practical techniques:
- Return the new state from the command (the aggregate knows it) so the UI updates immediately.
- Read-your-writes: the command returns the event's global position; the query API waits until the projection has processed at least that position (with a timeout).
- Serve critical checks from the write side. Balance validation happens in the aggregate, never against a projection.
Building Projections (Read Models)
A projection (projector, denormaliser) subscribes to events and updates a read model. Each projection tracks a checkpoint: the last global position it processed.
def run_balance_projection(conn, batch=500):
while True:
with conn.transaction():
(checkpoint,) = conn.execute(
"SELECT position FROM checkpoints WHERE name = 'balances' FOR UPDATE").fetchone()
rows = conn.execute(
"""SELECT global_position, stream_id, type, data FROM events
WHERE global_position > %s ORDER BY global_position LIMIT %s""",
(checkpoint, batch)).fetchall()
if not rows:
break
for pos, stream_id, etype, data in rows:
if etype == "AccountOpened":
conn.execute(
"INSERT INTO account_balances (account_id, owner, balance) "
"VALUES (%s, %s, 0) ON CONFLICT DO NOTHING",
(stream_id, data["owner"]))
elif etype == "MoneyDeposited":
conn.execute("UPDATE account_balances SET balance = balance + %s "
"WHERE account_id = %s", (data["amount"], stream_id))
elif etype == "MoneyWithdrawn":
conn.execute("UPDATE account_balances SET balance = balance - %s "
"WHERE account_id = %s", (data["amount"], stream_id))
conn.execute("UPDATE checkpoints SET position = %s WHERE name = 'balances'",
(rows[-1][0],))
Because the read model update and the checkpoint update share one transaction, a crash never applies an event twice or skips one. When the read model lives in a different system (Elasticsearch, Redis), you cannot share a transaction, so make updates idempotent (for example, store the last applied stream version per document and ignore older events).
Rebuilding projections
The superpower: to fix a bug in a projection or add a new one, create a fresh table, reset the checkpoint to 0, and replay. Run the new version alongside the old, switch reads when it catches up (blue-green projections). For large histories this can take hours, so measure replay throughput early and design projectors to process batches efficiently.
Example read models for the bank
| Read model | Store | Serves |
|---|---|---|
| Current balances | Postgres table | App home screen |
| Statement lines with running balance | Postgres, partitioned by month | Statement page, PDF export |
| Transaction search | Elasticsearch | "Find payments to Swiggy last year" |
| Spending by category | ClickHouse / warehouse | Insights dashboard |
| Velocity features | Redis | Real-time fraud checks |
Snapshots: Keeping Aggregate Loads Fast
A savings account with 20 transactions a month accumulates ~2,400 events in 10 years. Replaying 2,400 small events is a few milliseconds and usually fine. But a merchant's settlement account receiving 50,000 payments a day would be impossible to replay per command. A snapshot stores the aggregate's state at version N, so loading means "latest snapshot + events after N".
CREATE TABLE snapshots (
stream_id TEXT PRIMARY KEY,
version INT NOT NULL,
state JSONB NOT NULL,
schema_ver INT NOT NULL
);
-- load: SELECT state, version FROM snapshots WHERE stream_id = ?;
-- SELECT * FROM events WHERE stream_id = ? AND version > :snap_version ORDER BY version;
- Take snapshots every N events (say 100–1,000) or asynchronously in the background.
- Snapshots are a cache: if the aggregate's shape changes, discard them (bump
schema_ver) and rebuild from events. - If you need snapshots constantly, ask whether the aggregate boundary is wrong. A merchant account receiving 50,000 events per day might be better modelled as daily settlement batches, each its own stream.
Event Versioning, Upcasting and GDPR
Events are stored forever, but your understanding of the domain changes. Next year, MoneyWithdrawn needs a channel field (ATM, UPI, card). Old events do not have it. Strategies, from least to most invasive:
- Additive, optional fields. New readers default missing fields (
channel = "UNKNOWN"). Never remove or rename fields in place. - Upcasting. When reading, transform old event versions to the new shape in code before they reach the aggregate or projection:
MoneyWithdrawn_v1 → MoneyWithdrawn_v2. Store a schema version in metadata. - New event type. If semantics change, introduce
CashWithdrawnAtAtmrather than overloading the old one. - Copy-and-transform migration. Rewrite the stream into a new store. Last resort; you lose the "never modify history" property.
Personal data and the right to erasure
Immutable events conflict with data protection laws that give users a right to deletion (GDPR in the EU; India's Digital Personal Data Protection Act, 2023 also provides for erasure). Options:
- Keep PII out of events. Store
customerIdin the event; name, phone and address live in a mutable profile store that can be deleted. - Crypto-shredding. Encrypt PII fields with a per-user key stored separately. To "forget" a user, delete the key; the events remain but the PII is unreadable.
- Remember projections, snapshots, backups and downstream copies hold PII too.
Financial regulations may also require you to retain transaction records for years, so involve compliance before deciding.
When NOT to Use Event Sourcing (and CQRS)
Event sourcing has strong advocates and many stories of teams regretting it. Be honest about fit.
| Good fit | Poor fit |
|---|---|
| Ledgers, wallets, payments, trading, insurance claims | Simple CRUD: user settings, product catalogue admin, blog CMS |
| Audit and "how did we get here" is a business requirement | History has no value to anyone |
| Complex domain with rich behaviour and workflows | Mostly reads of data entered through forms |
| Many different read models over the same data | One screen per table |
| Team experienced with DDD and async systems | Team new to the pattern on a tight deadline |
| Applied to one bounded context that needs it | Applied to the entire system "for consistency" |
Cheaper alternatives that give part of the value
- Audit table written in the same transaction as the state change.
- Change data capture (Debezium) to stream row changes to Kafka for other consumers.
- Temporal tables (SQL:2011 system-versioned tables in SQL Server, MariaDB; extensions in Postgres) for "as of" queries.
- Transactional outbox to publish domain events while keeping state-based storage (see saga pattern and outbox).
How Event Sourcing and CQRS Come Up in Interviews
These patterns come up in system design rounds for wallets and ledgers (digital wallet design), payments, order management, collaborative editing, and in senior backend or architecture discussions.
Questions to expect
- "How would you design a wallet so you can prove every balance?" Double-entry ledger as an append-only event log per account; balances as projections; reconciliation by replay.
- "How do you prevent two concurrent debits from overdrawing?" Expected-version append on the account stream; retry on conflict.
- "How do reads stay fast?" Projections into tables shaped for each query; snapshots for long-lived aggregates.
- "A user just paid, but their history doesn't show it. Why?" Projection lag; read-your-writes via position tokens or returning state from the command.
- "How do you add a field to an event?" Additive change plus upcasting; never mutate stored events.
- "Event sourcing vs CDC?" CDC captures row changes (what changed); event sourcing captures domain intent (why: MoneyWithdrawn vs balance updated).
- "How does this interact with other services?" Publish events (or integration events derived from them) to Kafka; downstream services build their own models; cross-service flows via sagas.
How to present it
"For the ledger I'd use event sourcing because auditability and replay are requirements, not nice-to-haves. Each wallet is a stream; debits append with an expected version so concurrent debits can't overdraw. The balance and statement are projections in Postgres; search and analytics are separate projections. Everything else, like KYC profiles and merchant settings, stays plain CRUD. If projection lag matters for the payer, the command response carries the new balance."
That answer shows you know where to apply the pattern, not just what it is. For the related building blocks, see Kafka vs RabbitMQ vs SQS for the distribution log and microservices vs monolith for drawing the boundaries.
Frequently Asked Questions
Learn Architecture Patterns the Interview Way
Prepflix's system design track teaches event sourcing, CQRS, sagas and when each is worth the complexity, using real product-company problems.
Explore the Prepflix Course →