Skip to content

    System Design

    System Design: CQRS and Event Sourcing

    Learn how separating reads from writes and storing changes as events works, what projections and snapshots are, and when the extra complexity is not worth it.

    By Published 15 min read

    Share

    This lesson is part of Advanced System Design and Case Studies. Enroll for free, then sign in, to read it in full.

    What you'll learn: By the end of this lesson you will be able to explain CQRS and event sourcing as separate ideas, design an event store with projections and snapshots, reason about the eventual consistency of read models, handle replay and event schema changes, and decide when these patterns are not worth their cost.

    Think about a bank account as a ledger rather than one number that is overwritten each time you spend money: deposit 500, withdraw 120, deposit 80. The balance is simply what you get by adding up the ledger. If there is a dispute, you can replay history. If an auditor wants to know the balance on 3 March, you add up entries up to that date.

    Separately, the bank keeps a fast summary view for your app screen, because adding up ten years of entries on every page load would be silly. That summary is rebuilt from the ledger and is allowed to lag the ledger by a moment.

    Those two ideas, a ledger of facts and a fast separate read view, are the heart of event sourcing and CQRS. They are powerful and also easy to over-use, so we will spend as much time on the costs as on the benefits.

    The core idea

    CQRS (Command Query Responsibility Segregation) means using separate models for changing data (commands) and reading data (queries). Event sourcing means storing every change as an immutable event, and deriving current state from the sequence of events.

    These two patterns are independent:

    • You can do CQRS without event sourcing: write to a normalised relational database and maintain a denormalised search index or cache for reads.
    • You can do event sourcing without CQRS, though in practice reading current state by replaying events on every request is slow, so event-sourced systems almost always add read models.

    They fit together naturally because events are an excellent feed for building read models.

    Separating reads and writes (CQRS)

    In a typical create-read-update-delete design, one model serves both purposes. That is simple and works well for most applications. Problems appear when reads and writes pull the model in opposite directions:

    • Writes want normalised data, strict validation, and transactions that protect invariants.
    • Reads want denormalised, pre-joined shapes tuned for each screen, often at many times the volume of writes.
    • Reads and writes scale differently, and one may need a different kind of store (relational for writes, a search engine or key-value store for reads).

    CQRS splits the system into two sides.

    CQRS: commands update the write model, a projector feeds the read model, queries read itClient to Command handler (command); Command handler to Write model; Write model to Projector (changes); Projector to Read model; Client to Query handler (query); Query handler to Read modelClientClientCommand handlerCommandhandlerWrite modelWrite modelProjectorProjectorQuery handlerQuery handlerRead modelRead modelcommandchangesquery
    • A command expresses intent and can be rejected: PlaceOrder, CancelOrder. It returns success or failure (perhaps with an id or version), not a rich data view.
    • A query only reads and never changes state.
    • The write model enforces business rules. The read model is shaped purely for querying.
    • A projector keeps the read model updated from changes on the write side.

    The price is a gap in time. Between a command being accepted and the read model reflecting it, a query may return old data. That is eventual consistency on the read side, and we return to it below.

    NOTE

    A lightweight form of CQRS needs no special infrastructure. A reporting database fed by a replica, or a materialised view, already separates read and write shapes. Start there before building a full command and query pipeline.

    Event sourcing: the log is the truth

    In a traditional design, the database row is the state; an update overwrites it, and history is lost unless you build an audit table. In event sourcing, the append-only event log is the source of truth, and the current state is a fold over it.

    An event is a named fact in the past tense, with data and metadata:

    {
      "streamId": "order-4821",
      "version": 3,
      "type": "ItemAdded",
      "data": { "sku": "BK-102", "qty": 2 },
      "occurredAt": "2026-10-18T09:12:44Z"
    }

    Events belong to streams, typically one per aggregate (an order, an account, a cart). Each event in a stream has a sequence number. The event store supports two core operations: append events to a stream, and read a stream from the start (or from a position).

    To handle a command:

    1. Load the aggregate's events and rebuild its current state.
    2. Check business rules against that state (can this order still be cancelled?).
    3. If allowed, produce new events and append them.

    Step 3 uses optimistic concurrency: you append "expecting version 7". If another writer has already added event 8, the append fails, and you reload and retry. That gives per-stream consistency without long-held locks.

    sequenceDiagram participant U as Client participant H as Command handler participant ES as Event store U->>H: CancelOrder 4821 H->>ES: Read stream order-4821 ES->>H: Events 1 to 7 Note over H: Rebuild state, check rules H->>ES: Append OrderCancelled, expect version 7 ES->>H: Appended as version 8 H->>U: Accepted

    What you gain:

    Benefit Why it follows from the design
    Complete audit trail Every change is recorded, and with who, what and when metadata you get a full trail by construction
    Time travel and debugging Rebuild state at any past moment; reproduce a bug from real history
    New read models later Add a new projection and rebuild it from historical events
    Natural integration Events can be published to other services
    Captures intent AddressChanged says more than an overwritten column

    Projections: building read models from events

    A projection is a function that consumes events and maintains a read-optimised view. One event stream can feed many projections: an "orders by customer" table, a search index, a dashboard counter, a data warehouse feed.

    One event store feeding three projectors and three read modelsEvent store to Projector A; Event store to Projector B; Event store to Projector C; Projector A to Orders by customer; Projector B to Search index; Projector C to Revenue countersEvent store: append-only logEvent storeappend-only logProjector AProjector AProjector BProjector BProjector CProjector COrders by customer: relational tableOrders bycustomerrelational tableSearch indexSearch indexRevenue counters: key-value storeRevenuecounterskey-value store

    Each projector remembers a checkpoint (the last event position it processed). After a crash it resumes from the checkpoint. Because delivery can repeat events, projections must be idempotent: applying the same event twice must not corrupt the view. Common approaches are upserts keyed by entity id, or storing the last processed version with the row and ignoring events at or below it. This echoes the idempotency discussion in Distributed Transactions.

    Projections are disposable. If a view has a bug, or a new query needs a new shape, you fix the projector, delete the view and rebuild it by replaying the log. That is a major operational advantage, but it has a cost: replay of a long history can take hours, so plan for it, ideally by building the new version side by side and switching traffic when it catches up.

    Snapshots: keeping rebuild fast

    Rebuilding an aggregate from its first event is cheap for an order with ten events. For an account with a million events, it is not. A snapshot is a saved copy of an aggregate's state at a given version. To load, read the latest snapshot and replay only the events after it.

    Snapshots are a performance cache, not a source of truth. You can always discard and recreate them from the log. Typical policy: take a snapshot every N events (for example, every few hundred), or when loading takes too long. Do not add snapshots until you measure a problem. Many aggregates stay small enough that they never need them, and well-designed aggregates have short lifetimes (a shopping cart, an order) rather than infinite ones.

    TIP

    If an aggregate's stream grows without bound, check the modelling first. A "customer" that accumulates every event ever may really be many smaller aggregates, such as separate orders, with the customer holding only references.

    Eventual consistency on the read side

    Because projections run after the write, the read model lags. Usually the lag is milliseconds, but during load spikes or a projector outage it can be seconds or minutes. The classic symptom: a user submits a form, is redirected to a list page, and does not see their change.

    Technique How it works Trade-off
    Return the result from the command Command response includes the new state or id, and the UI shows it directly Simple; covers the common "post then redirect" case
    Optimistic UI The client shows the expected result immediately and reconciles later Needs care when the command may be rejected
    Read-your-writes by version Command returns a version; the query waits until the read model reaches it Adds latency and plumbing; needs per-view checkpoints
    Read from the write side for that user Route a user's reads for recent changes to the source of truth Increases load on the write side
    Set expectations Show "processing" states in the UI Honest, but needs product buy-in

    Decide, feature by feature, how stale each read is allowed to be. A product listing can lag seconds. A "balance after transfer" screen probably should not. For the consistency vocabulary, revisit CAP and Consistency Models.

    Event schema evolution

    Events are immutable and may live for years, so the code that reads them must handle every shape they ever had. Requirements change, and so will events. Strategies:

    • Additive changes first. Adding an optional field is usually safe: old events lack it, readers use a default. Never reuse a field name for a different meaning.
    • Versioned event types. Introduce OrderPlacedV2 and keep a handler for OrderPlacedV1.
    • Upcasting. On read, transform old events into the latest shape in code, so the rest of the system sees only the current version. The stored event stays untouched.
    • Explicit schemas with a registry. Use a schema format with explicit compatibility rules (for example Avro, Protobuf or JSON Schema with a schema registry) and enforce backward compatibility in CI.
    • Copy-and-transform migration. For a breaking change, write a new stream (or a new store) from the old events, transformed, then switch over. This is heavy, and is a reason to design events carefully.

    Two further points deserve care:

    • Events are facts, not commands. Name them in past tense and record what happened, not what the next step should do.
    • Privacy and deletion. An append-only log conflicts with "delete my data" obligations. Common approaches are keeping personal data out of events (store a reference), encrypting personal fields with a per-subject key that can be destroyed (sometimes called crypto-shredding), or keeping personal data in a mutable store. Decide this before launch, because retrofitting is painful.

    When the cost is not worth it

    CQRS and event sourcing are not defaults. They add moving parts: an event store, projectors, checkpoints, rebuild procedures, versioned schemas, and developers who must think in a different style. Each of those has to be operated and debugged.

    Situation Better choice Why
    Simple CRUD with modest load Plain relational model Fewer parts; consistency is trivial
    Need an audit trail only Audit table or change data capture Gives history without making events the source of truth
    Reads heavier than writes, same data shape Read replicas and caching No second model to maintain
    Search or analytics needs a different shape CQRS-lite: replica, materialised view or search index Separate read model without event sourcing
    Strong consistency required everywhere Single transactional store Eventual consistency would violate the requirements
    Team new to the pattern, tight deadline Defer Mistakes in event design are expensive to undo
    flowchart TD Q1{"Is history itself a requirement?"} -->|yes| ES["Event sourcing for that area"] Q1 -->|no| Q2{"Do reads need a different shape or store?"} Q2 -->|yes| Q3{"Can a replica or materialised view serve them?"} Q3 -->|yes| LITE["CQRS-lite with a replica or view"] Q3 -->|no| CQ["CQRS with a separate read store"] Q2 -->|no| CRUD["Plain CRUD model"]

    Read it as a series of cheaper exits: most systems leave at plain CRUD or CQRS-lite.

    Event sourcing earns its keep when the history itself is the product or a regulatory need (ledgers, trading, order lifecycles with complex state), when you need many different views of the same facts, or when you must reconstruct past states. Even then, apply it to the parts of the system that benefit, not everything. Systems that use it often confine it to one or two bounded areas next to ordinary CRUD services.

    WARNING

    Event sourcing is not a message bus, and an event store is not automatically a good general-purpose broker. Publishing events to other services is a separate concern, and you should still use patterns like the outbox so that writing the event and announcing it do not diverge. See Event Streaming for the log-based tools often used to carry them.

    In practice: a digital-wallet ledger service

    NOTE

    A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.

    The situation

    A consumer wallet app supports top-ups, peer-to-peer transfers and merchant payments for about 1.5 million users (assumption). Twelve engineers run Java services on Kubernetes with PostgreSQL, Kafka, Redis and an OpenSearch cluster for transaction search. Today a balances table is updated in place next to a transactions table. The numbers that hurt (illustrative): a weekly reconciliation finds roughly 0.3 percent of accounts where the balance and the transaction sum disagree, the month-end statement queries push balance-read p99 to around 900 ms, and auditors ask for "the balance of account X at close of business on 31 March", which the team answers with ad hoc scripts.

    Starting architecture

    Starting architecture: wallet service writing a balances table and a transactions tableMobile app to Wallet API; Wallet API to Wallet service; Wallet service to balances table; Wallet service to transactions table (insert); Wallet API to transactions table (statements and search); transactions table to OpenSearchSAME DATABASEMobile appMobile appWallet APIWallet APIWallet serviceWallet servicebalances table: updated in placebalances tableupdated in placetransactions tabletransactionstableOpenSearchOpenSearchinsertstatements and search

    Two tables are written separately, so they can drift, and heavy read traffic lands on the same database as money movement.

    Options considered

    Option What it fixes What it costs Risk
    A. Smaller change: add an audit table and a read replica Statement load, partial history Days The drift bug and as-of queries remain
    B. Append-only ledger as the source of truth in PostgreSQL, with projections for balances, statements and search Drift, history, as-of queries, read load A quarter of work; projector operations Team learns event design; read lag is visible
    C. Adopt a dedicated event store product and full CQRS across all wallet features Same as B, with purpose-built tooling New infrastructure and skills Large blast radius for a 12-person team

    The decision

    Field Entry
    Context Money needs an immutable history and a balance that is always explainable; reads and writes have very different shapes.
    Decision Adopt B for the ledger only. Each account is a stream of entries (version number, amount in minor units, currency, transfer id). A unique key on account id and version gives optimistic concurrency. A transfer writes both entries, plus an outbox row for Kafka, in one local transaction. Debit decisions are made from the account's own entries (add a snapshot only if replay becomes slow), not from the lagging read model.
    Consequences Good: balance equals the sum of entries by construction, any past balance is a query, new views can be backfilled. Bad: statements and search lag by seconds, and event schemas must be versioned for years.
    Revisit when Replaying one account takes longer than the debit latency budget, or a full projection rebuild exceeds the maintenance window.
    erDiagram ACCOUNT ||--o{ LEDGER_ENTRY : "has stream of" ACCOUNT ||--|| BALANCE_VIEW : "summarised by" ACCOUNT ||--o{ STATEMENT_LINE : "listed in" ACCOUNT { string id PK } LEDGER_ENTRY { string account_id FK int version string type int amount_minor string currency string transfer_id timestamp occurred_at } BALANCE_VIEW { string account_id PK int balance_minor int last_version } STATEMENT_LINE { string account_id FK string transfer_id timestamp occurred_at }

    The unique pair (account_id, version) is the whole concurrency mechanism: a second writer with the same expected version fails and retries.

    Rollout and verification

    flowchart LR BF["Backfill ledger
    from history"] --> SH["Shadow period
    both models written"] SH --> CMP["Nightly comparison
    of balances"] CMP --> CUT["Cut over by cohort"] CUT --> RET["Retire old balances table"]

    Backfill generates entries from the existing transactions. Then the service writes to both models while a nightly job compares the old balance with the folded ledger for every account. Cut over by cohort (staff, then 5 percent, then the rest) once the comparison shows zero differences for an agreed period such as 14 days. Dashboards: projection lag (p99 target of a couple of seconds), count of comparison mismatches (target zero), optimistic-concurrency retries per minute, and rebuild time measured in a rehearsal.

    The 2 a.m. failure

    The page reads: "statements projection lag above 5 minutes". A newly released event type reached the statements projector, which cannot parse it, retries forever and holds its checkpoint. Debits still work, because they use the write side.

    sequenceDiagram participant L as Ledger participant K as Kafka participant P as Statements projector L->>K: Outbox relay publishes new entry type K->>P: Deliver event P->>P: Parse fails P->>K: No commit, retry Note over P: Checkpoint stuck, lag grows P->>K: After fix, resume from checkpoint

    Detection: projection_lag_seconds{projection="statements"} and a repeating parse error in the logs. First three checks: read the consumer group lag (for example with kafka-consumer-groups.sh --describe --group <group>), find the offset that keeps failing, and inspect that event against the schema. Mitigation: deploy a projector that tolerates the new field, or route the single bad event to a dead-letter topic with a ticket and backfill that statement line once the projector is fixed. Meanwhile the app shows the transfer from the command response. Follow-up: a CI check that replays recorded events through every projector, and a compatibility gate before a new event type ships.

    Architect's notes

    • Split the decision by need. Use event sourcing where history is a requirement (the ledger), and plain tables for profiles, settings and notifications.
    • Money decisions read the write side. Any check that must be right, such as "enough balance", must not depend on an eventually consistent view.
    • Ask in the review: "How long does a full rebuild take, and what happens to users meanwhile?" Rehearse it before you need it.
    • Cost trap. Event schemas outlive the team that wrote them. Budget for upcasters and compatibility tests from day one.
    • Privacy. Decide early which personal fields never go into events.
    • Defer on purpose. Snapshots and a dedicated event-store product can wait until measurements demand them.

    Common mistakes

    • Using the patterns by default. Both add real operating cost. Start with a simple model and adopt them where a concrete need (audit, many views, history) justifies it.
    • Confusing CQRS with event sourcing. They are separate. Adopt only the part you need; CQRS-lite with a replica or search index often suffices.
    • Non-idempotent projections. Replays and retries will double-count. Key updates by entity and version, and test by replaying the same events twice.
    • Ignoring read-side lag in the user experience. Users think the write failed. Return results from commands, use optimistic UI, or wait on versions where it matters.
    • Careless event design. Events named like database operations (RowUpdated) lose intent and are hard to evolve. Model business facts, and plan for versioning from the start.
    • Unbounded streams and no snapshot plan. Loading becomes slow. Model smaller aggregates, and snapshot only when measured need arises.
    • Forgetting data deletion rules. An immutable log and privacy law collide. Keep personal data out of events or use encryption keys you can destroy.

    Key takeaways

    • CQRS separates intent from view. Commands change state and can be rejected; queries read optimised models.
    • Event sourcing stores facts. The append-only log is the source of truth, and current state is derived by folding events.
    • The two ideas are independent. Many systems use CQRS alone; event sourcing almost always needs read models.
    • Projections are disposable. Rebuild them by replay, keep them idempotent, and checkpoint their position.
    • Snapshots are caches. Add them only after measuring slow loads, and never treat them as authoritative.
    • Read models are eventually consistent. Design the user experience around lag with command responses, optimistic UI, or version waits.
    • Events live forever, so evolve them carefully. Prefer additive changes, upcasting and enforced compatibility, and plan for privacy deletion.
    • Use sparingly. Apply these patterns where history or many views matter, and keep ordinary CRUD where it suffices.

    Practice exercises

    Exercise 1 — Starter (30 min): For a bank account, list the commands and events you would define (at least four events, past-tense names). Then compute the balance by folding this sequence: Opened(0), Deposited(500), Withdrew(120), Deposited(80), Withdrew(60).

    Exercise 2 — Intermediate (2 hours): Design an event-sourced order aggregate with the events OrderPlaced, PaymentReceived, OrderShipped and OrderCancelled. Write the rules for which commands are valid in which state (a small state diagram helps), show how optimistic concurrency stops two simultaneous commands from both succeeding, and define two projections with an idempotency approach for each.

    Exercise 3 — Advanced (half day): Your OrderPlaced event must change: the single address string becomes a structured address with separate fields, and personal details must be removable on request. Propose a migration using upcasting, then a second using a copy-and-transform rebuild. Compare them on risk, downtime, replay time and storage. Describe how you would keep personal data deletable, and define the rollout plan, including how you would rebuild a projection side by side and cut over safely.

    Enroll to keep reading

    Lesson 3 of 14 · Advanced System Design and Case Studies

    Learn how separating reads from writes and storing changes as events works, what projections and snapshots are, and when the extra complexity is not worth it.

    In the rest of this lesson

    • Projections: building read models from events
    • Snapshots: keeping rebuild fast
    • Eventual consistency on the read side
    • Event schema evolution
    • When the cost is not worth it
    • In practice: a digital-wallet ledger service

    Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments