CQRS and Event Sourcing: Separating Reads from Writes

CQRS explained with real examples (MySQL + Elasticsearch, DynamoDB notifications), lightweight CQRS, cloud implementations, and event sourcing.

Advanced 6 min read · Updated

CQRS (command query responsibility segregation) separates the part of a system that changes data from the part that reads it, so each can use its own model, its own storage and its own scaling. Event sourcing stores every change to an entity as an immutable event (MoneyDeposited, ItemAddedToCart) instead of storing only its current state, and derives the current state by replaying events. They're often used together, but each can be used alone.

Why separate reads and writes

Reads and writes usually have very different traffic. On a social network millions of people scroll feeds while a small fraction post. An online store sees far more browsing than buying, and a content platform far more consumers than creators. An IoT system is the opposite: devices write constantly and people rarely read. That asymmetry is an opportunity: handle reads and writes differently and scale each independently.

Level 1: database replication

The classic form of read/write separation is replication: one primary database takes all writes, and several replicas that continuously copy it serve reads.

syncWritesReadsPrimary DBReplicaReplica
Read/write separation with replication: writes go to the primary; replicas sync from it and serve reads.
  • Gains: much higher read capacity, better availability and fault tolerance (if a replica fails, others keep serving reads), and read capacity that grows by adding replicas.
  • Costs: replication lag, the delay before a write appears on replicas, so readers can briefly see stale data; and you need a solid failover plan for when the primary fails.

Level 2: CQRS

CQRS goes further. Instead of copying the same data, it splits the whole data-access architecture into a command path (create, update, delete) and a query path (read). It's the same idea as replication, but done at the application level, so the two sides can have entirely different models.

  • Commands (mutations, the C, U and D of CRUD) change state. They use a normalized model built for consistency and integrity, often in a relational database with ACID transactions.
  • Queries (the R) read state. They use denormalized views shaped for the questions users ask, often in a store built for fast reads, such as a document database or search engine.
Clientcommands (create, update, delete)Write serviceWrite DBnormalizedEventsasync updateClientqueries (read)Read serviceRead DBdenormalized views
CQRS: commands go to the write service and write database; changes flow as events to a separate read database that the query service reads.

Eventual consistency is central to most CQRS systems. When a command runs, the system emits events describing the change, and those events update the read models asynchronously. The write side is never slowed down by having to update every read view immediately, and each side can evolve on its own. The price is a short window in which the read side doesn't yet show the latest write.

Real-world CQRS examples

MySQL for commands, Elasticsearch for queries

A classic pairing when a system needs both ACID transactions and rich search. Changes flow from MySQL to Elasticsearch through events or change data capture.

Command side: MySQLQuery side: Elasticsearch
Order processing and inventoryFull-text product search
User account changesComplex filter combinations
Financial transactionsFaceted navigation (brand, price, rating counts)
Referential integrity (foreign keys)Real-time search suggestions, analytics and reporting

One database, two table shapes: a notification system

The two sides can also live in the same database as different tables; the query side is simply a denormalized view optimized for reads. Take notifications in DynamoDB. The write table gets simple, fast inserts: no data transformation and no aggregates to update:

// notifications_write: optimized for fast inserts
{
  "notification_id": "uuid-123",
  "user_id": "user_789",
  "type": "friend_request",
  "sender_id": "user_456",
  "created_at": "2024-03-20T10:30:00Z",
  "status": "unread"
}

The read table holds pre-computed views: one item per user per month, with the sender's details already joined in and the unread count already calculated:

// notifications_read: optimized for the "open my notifications" screen
{
  "user_id": "user_789#2024-03",          // partition key: one feed per user per month
  "unread_count": 5,                       // pre-calculated aggregate
  "notifications": [
    {
      "id": "uuid-123",
      "type": "friend_request",
      "sender": { "id": "user_456", "name": "John Doe", "avatar_url": "https://..." },
      "created_at": "2024-03-20T10:30:00Z",
      "status": "unread"
    }
  ]
}

Reading the notifications screen is now one key lookup: pre-joined data (no extra queries for sender names), pre-calculated aggregates (unread_count), a key designed for the exact access pattern, and reads that scale independently of writes.

Without CQRSWith CQRS
Writes must update several aggregates at onceThe write service just records the notification quickly
Reads join across many recordsAn async processor enriches the data (fetches sender details)
One table design compromises between write and read patternsThe read model keeps pre-computed views shaped for each screen
Reads and writes can't scale separately when their loads differEach side scales on its own load

Pros and cons of CQRS

AdvantagesDrawbacks
Faster reads: denormalized structures and pre-computed views; heavy read traffic doesn't slow writes, and vice versaComplexity: two models plus the synchronization between them make the system harder to understand and maintain
Complex queries: use specialized stores, such as Elasticsearch for search, without hurting transactional writes in MySQLEventual consistency: the read side can show outdated data for a while; the product and users must tolerate that
Decoupled evolution: each side can change its model and technology independentlyInfrastructure overhead: extra databases and message queues to run, monitor and deploy together

Lightweight CQRS

Not every CQRS system needs two databases and an asynchronous pipeline. In smaller systems the command and query models often share one database and are kept in sync within the same transaction. You keep separate command and query models in the code, keep strong consistency, and add no infrastructure. Teams typically grow from this into the "commands in the OLTP (transactional) store, queries in a read store" design only when scale or specialized queries justify the extra moving parts.

CQRS on the major clouds

CloudWrite (command) sideEventsRead (query) side
AWSAWS Lambda functions handle commandsAmazon SNS or SQS; EventBridge coordinates event flowsDynamoDB with global secondary indexes; ElastiCache or OpenSearch (Elasticsearch) to speed up queries
AzureAzure Functions or App Service process commandsAzure Service Bus or Event GridCosmos DB or Azure SQL Database holding denormalized views, maintained by projection Functions
Google CloudCloud Functions process commandsPub/SubFirestore or BigQuery, updated by Cloud Functions triggered by the events; good for analytics and reporting

Event sourcing

A traditional table stores balance = 1,200, losing how it got there. An event-sourced account stores the history; the balance is a fold over it:

#EventDataBalance after
1AccountOpenedowner: Asha0
2MoneyDeposited1,5001,500
3MoneyWithdrawn2001,300
4FeeCharged1001,200
  • The event store is append-only. Events are never updated or deleted; mistakes are corrected with new compensating events.
  • To handle a command ("withdraw 500"), load the entity's events, rebuild its state, check business rules, and append new events, using an expected version so concurrent commands can't both succeed (optimistic concurrency).
  • Snapshots (state at event 10,000) avoid replaying long histories every time.
CommandsPlaceOrderWrite modelrules, versionsEvent storeappend-onlyProjectorsOrder history viewSearch indexQueries
CQRS with event sourcing: commands are validated against the write model and stored as events; projectors build read models for each query, slightly behind the writes.

What event sourcing gives you

  • Complete audit trail: every change, who made it and when, by construction. Valuable in finance, healthcare and compliance-heavy domains.
  • Time travel: reconstruct state at any past moment to debug or answer "what did we know then?"
  • New views from old data: add a read model later and build it by replaying history.
  • Natural integration: the events are already there for other services to consume. See event streaming.

What it costs

  • Eventual consistency on reads: projections lag the write. The UI must cope, for example by showing the user's own command result directly.
  • Event schema evolution: events are forever, so old versions must remain readable (upcasting, versioned events).
  • Complexity: rebuilding projections, handling replays idempotently, and reasoning about two models is real work. Debugging needs good tooling.
  • Deletion requirements: privacy laws may require erasing personal data from an immutable log; techniques like crypto-shredding (delete the per-user key) are needed.

Apply event sourcing to the parts of a domain where history and auditability are core (ledgers, orders, workflows), not to a whole application by default. CQRS read models, on the other hand, are useful almost anywhere reads and writes differ, even without event sourcing.

Where it’s used

E-commerce search

Orders and inventory live in a relational database; product search, filters and facets are served from Elasticsearch kept in sync by events or CDC.

Notification feeds

Raw notifications are written fast; per-user, pre-joined feeds with unread counts are maintained asynchronously for one-lookup reads.

Banking and accounting ledgers

Ledgers are inherently event-sourced: balances are sums of immutable entries, never edited in place.

Git

A repository is an event log of commits; any version of the code can be rebuilt by replaying history.

In an interview

  • Start with replicas for read/write separation; move to CQRS when reads need a different shape or store (search, feeds, analytics).
  • Describe the command model (normalized, transactional) and the query model (denormalized, per screen), updated asynchronously by events.
  • Address read lag in the UX, and mention the lightweight single-database variant for smaller systems.
  • Suggest event sourcing only when auditability or history is a requirement; mention snapshots and event versioning.