System Design · Architecture Patterns

Event Sourcing & CQRS Explained with Real Examples

Store what happened instead of what is, then build as many read models as you need from it. A complete bank account example in Java and Python, plus the operational realities most tutorials skip.

✍️ Pranjal Jain, Ex-Microsoft · IIT Kanpur 📅 10 October 2026 ⏱ 16 min read
Illustration of an append-only stream of events feeding several different read model displays
TL;DR
  • 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:

VersionEventBalance after
1AccountOpened { owner: "Asha" }0
2MoneyDeposited { amount: 5000 }5,000
3MoneyWithdrawn { 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.

Java
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.

Python
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

  1. Load. Event store returns [AccountOpened, MoneyDeposited(500000)] for ACC-1. Replay: open = true, balance = 5,00,000 paise (₹5,000), version = 2.
  2. Command. withdraw(80000). Checks pass (80,000 ≤ 5,00,000). Records MoneyWithdrawn(80000); in-memory balance = 4,20,000.
  3. Save. Append pending events to stream ACC-1 expecting version 2. Succeeds; stream is now at version 3.
  4. 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
);
Python (psycopg)
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

OptionNotes
Postgres/MySQL tableSimple, transactional, familiar ops. Fine for most systems. Marten (.NET) builds on Postgres.
EventStoreDB / KurrentDBPurpose-built: streams, expected-version appends, subscriptions, projections.
Axon ServerEvent store + message routing for the Axon Framework (Java).
DynamoDBPartition key = stream ID, sort key = version, conditional writes for concurrency; DynamoDB Streams for projections.
KafkaGreat 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.

Commands Queries Client ─── POST /withdraw ───┐ ┌─── GET /statement ─── Client ▼ │ ┌──────────────────┐ │ ┌───────────────────────┐ │ Command handler │ │ │ Query API │ │ load aggregate, │ └─►│ reads read models │ │ validate, append │ └──────────┬────────────┘ └────────┬─────────┘ │ ▼ │ ┌──────────────────┐ ┌────────┴────────────┐ │ Event store │──────►│ Projectors │ │ (source of truth)│ events│ ├─ balances (SQL) │ └──────────────────┘ │ ├─ statement table │ │ ├─ search (ES) │ │ └─ fraud features │ └─────────────────────┘

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.

Python
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 modelStoreServes
Current balancesPostgres tableApp home screen
Statement lines with running balancePostgres, partitioned by monthStatement page, PDF export
Transaction searchElasticsearch"Find payments to Swiggy last year"
Spending by categoryClickHouse / warehouseInsights dashboard
Velocity featuresRedisReal-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:

  1. Additive, optional fields. New readers default missing fields (channel = "UNKNOWN"). Never remove or rename fields in place.
  2. 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.
  3. New event type. If semantics change, introduce CashWithdrawnAtAtm rather than overloading the old one.
  4. 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 customerId in 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 fitPoor fit
Ledgers, wallets, payments, trading, insurance claimsSimple CRUD: user settings, product catalogue admin, blog CMS
Audit and "how did we get here" is a business requirementHistory has no value to anyone
Complex domain with rich behaviour and workflowsMostly reads of data entered through forms
Many different read models over the same dataOne screen per table
Team experienced with DDD and async systemsTeam new to the pattern on a tight deadline
Applied to one bounded context that needs itApplied 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).
⚠️
The most common failure modeTeams adopt event sourcing system-wide, then find that every simple screen requires a projection, every schema change requires an upcaster, and every new developer needs weeks to become productive. Scope it to the bounded context where history is the product, such as the ledger, and keep the rest CRUD.

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

  1. "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.
  2. "How do you prevent two concurrent debits from overdrawing?" Expected-version append on the account stream; retry on conflict.
  3. "How do reads stay fast?" Projections into tables shaped for each query; snapshots for long-lived aggregates.
  4. "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.
  5. "How do you add a field to an event?" Additive change plus upcasting; never mutate stored events.
  6. "Event sourcing vs CDC?" CDC captures row changes (what changed); event sourcing captures domain intent (why: MoneyWithdrawn vs balance updated).
  7. "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

What is the difference between event sourcing and CQRS?▾
Event sourcing is a persistence pattern: you store every state change as an immutable event and derive current state by replaying them. CQRS is an architectural pattern: you separate the model that handles writes (commands) from the models that serve reads (queries). They are often used together because an event log is a natural way to feed separate read models, but each can be used without the other.
Is Kafka an event store?▾
Kafka can hold events durably and replay them, but it is not a good primary event store for event-sourced aggregates. It lacks efficient reads of a single aggregate's stream and has no built-in optimistic concurrency check on an expected stream version. Common setups store events in a database or a dedicated store such as EventStoreDB, then publish them to Kafka for projections and other services.
How do you query current state in an event-sourced system?▾
For the write side you load an aggregate by replaying its events, optionally starting from a snapshot. For queries you do not replay; you read from projections, which are read models such as SQL tables, search indexes or caches that are updated by processing the event stream. Queries are then as fast as any normal database read.
What are snapshots in event sourcing?▾
A snapshot is a saved copy of an aggregate's state at a particular version. To load the aggregate you read the latest snapshot and replay only the events after it, which bounds load time for long-lived aggregates. Snapshots are an optimization; they can be deleted and rebuilt from events at any time.
How do you handle GDPR deletion with immutable events?▾
Common approaches are to keep personal data out of events and reference it by ID from a separate store that can be deleted, or to encrypt personal fields with a per-user key and delete the key when the user asks to be forgotten, which is called crypto-shredding. Some stores also support rewriting or truncating streams as a last resort.
When should you not use event sourcing?▾
Avoid it for simple CRUD domains, for teams new to the pattern on tight deadlines, and where the history of changes has no business value. It adds complexity around eventual consistency, event versioning, projections and tooling. An audit log table or change data capture often gives most of the benefit at a fraction of the cost.
Does CQRS require two databases?▾
No. CQRS only means separating the write model from the read model. Both can live in the same database, for example normalized tables for writes and denormalized views or materialized views for reads. Separate databases make sense when read and write workloads need different technologies, such as Postgres for writes and Elasticsearch for search.

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 →
Pranjal Jain
Pranjal Jain

Ex-Microsoft SDE · IIT Kanpur · Founder of Prepflix. Helps engineers crack startup and product company interviews across India.