CQRS and Event Sourcing: Separating Reads from Writes
CQRS explained with real examples (MySQL + Elasticsearch, DynamoDB notifications), lightweight CQRS, cloud implementations, and event sourcing.
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.
- 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.
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: MySQL | Query side: Elasticsearch |
|---|---|
| Order processing and inventory | Full-text product search |
| User account changes | Complex filter combinations |
| Financial transactions | Faceted 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 CQRS | With CQRS |
|---|---|
| Writes must update several aggregates at once | The write service just records the notification quickly |
| Reads join across many records | An async processor enriches the data (fetches sender details) |
| One table design compromises between write and read patterns | The read model keeps pre-computed views shaped for each screen |
| Reads and writes can't scale separately when their loads differ | Each side scales on its own load |
Pros and cons of CQRS
| Advantages | Drawbacks |
|---|---|
| Faster reads: denormalized structures and pre-computed views; heavy read traffic doesn't slow writes, and vice versa | Complexity: 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 MySQL | Eventual 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 independently | Infrastructure 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
| Cloud | Write (command) side | Events | Read (query) side |
|---|---|---|---|
| AWS | AWS Lambda functions handle commands | Amazon SNS or SQS; EventBridge coordinates event flows | DynamoDB with global secondary indexes; ElastiCache or OpenSearch (Elasticsearch) to speed up queries |
| Azure | Azure Functions or App Service process commands | Azure Service Bus or Event Grid | Cosmos DB or Azure SQL Database holding denormalized views, maintained by projection Functions |
| Google Cloud | Cloud Functions process commands | Pub/Sub | Firestore 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:
| # | Event | Data | Balance after |
|---|---|---|---|
| 1 | AccountOpened | owner: Asha | 0 |
| 2 | MoneyDeposited | 1,500 | 1,500 |
| 3 | MoneyWithdrawn | 200 | 1,300 |
| 4 | FeeCharged | 100 | 1,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.
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
Orders and inventory live in a relational database; product search, filters and facets are served from Elasticsearch kept in sync by events or CDC.
Raw notifications are written fast; per-user, pre-joined feeds with unread counts are maintained asynchronously for one-lookup reads.
Ledgers are inherently event-sourced: balances are sums of immutable entries, never edited in place.
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.