December 30, 2025
CQRS & Event Sourcing: Separating Reads from Writes
Your database does two things:

By Kawaldeep Singh
6 min read
- Writes: User changes data (insert, update, delete)
- Reads: User views data (select, filter, aggregate)
They have different patterns:
Writes:
- Infrequent (1 write per user per minute)
- Complex (multi-step, validation)
- One at a time (serialized)
- Must be fast (user waiting)
Reads:
- Frequent (10 reads per user per minute)
- Simple (just fetch)
- Concurrent (many simultaneously)
- Can be slow (background task, cache OK if 1 second old)
Traditional databases optimize for both. Result: neither is great.
CQRS (Command Query Responsibility Segregation) + Event Sourcing solves this:
Separate them.
Writes go to an optimized write database. Reads go to an optimized read database. Events flow from write to read (asynchronously).
Benefits:
- โ Write database: Optimized for transactions, consistency, speed
- โ Read database: Optimized for queries, analytics, scale
- โ Reads don't slow writes: 10,000 reads don't block a write
- โ Scaling: Scale read and write databases independently
- โ Flexibility: Change read model without touching writes
- โ Auditing: All changes are events (can replay)
Here's what I've learned: CQRS is complex. Only use it when you need it (high read/write ratio, scale). But when you need it, it's a game-changer. 100x read scale with minimal write impact.
This article teaches you how to implement CQRS + Event Sourcing properly.
The Analytics Dashboard That Broke the Database
Last year, a company had a problem:
Users could write data fine. But the analytics dashboard was killing the database.
Dashboard query:
SELECT user_id, COUNT(*) as orders, SUM(amount) as total
FROM orders
WHERE created_at > NOW() - INTERVAL 30 DAYS
GROUP BY user_id
ORDER BY total DESCSELECT user_id, COUNT(*) as orders, SUM(amount) as total
FROM orders
WHERE created_at > NOW() - INTERVAL 30 DAYS
GROUP BY user_id
ORDER BY total DESCThis query:
- Scans 1 million rows (all orders last 30 days)
- Groups them
- Sorts them
- Takes 30 seconds
- Locks database during scan
- If dashboard refreshes every minute: Database always locked
Result: Write operations queue up, users experience delays.
They switched to CQRS:
Old (single database):
Write database: Stores actual order data
Read database: Same database (locked during query)
Problem: Reads block writesWrite database: Stores actual order data
Read database: Same database (locked during query)
Problem: Reads block writesNew (CQRS):
Write database: Orders table (optimized for inserts)
โโ Fast writes (100ms per order)
โโ Emits: OrderCreated event
Event stream: Kafka (durable)
Read database: Materialized view (pre-computed)
โโ user_id, order_count, total_amount (pre-aggregated)
โโ Updated asynchronously (1 second behind)
โโ Can't be locked (separate database)
Result:
- Write: Still 100ms (unaffected)
- Read: Now instant (pre-computed, < 10ms)
- Dashboard: Runs every 100ms without any impactWrite database: Orders table (optimized for inserts)
โโ Fast writes (100ms per order)
โโ Emits: OrderCreated event
Event stream: Kafka (durable)
Read database: Materialized view (pre-computed)
โโ user_id, order_count, total_amount (pre-aggregated)
โโ Updated asynchronously (1 second behind)
โโ Can't be locked (separate database)
Result:
- Write: Still 100ms (unaffected)
- Read: Now instant (pre-computed, < 10ms)
- Dashboard: Runs every 100ms without any impactNow:
- Write database handles 1000 writes/second
- Read database handles 10,000 reads/second
- Completely independent
- Dashboard can't break writes
Lesson: Separate reads from writes. Let them scale independently.
CQRS Patterns
Pattern 1: Simple CQRS (No event sourcing)
Write side:
User โ Command โ Domain Model โ Update DB
โโ Emit event
Read side:
โ Event triggers update of read database
User โ Query โ Read DB (optimized for queries)
Example:
- Write DB: Orders (normalized)
- Event: OrderCreated
- Read DB: DailyStats (denormalized aggregates)Write side:
User โ Command โ Domain Model โ Update DB
โโ Emit event
Read side:
โ Event triggers update of read database
User โ Query โ Read DB (optimized for queries)
Example:
- Write DB: Orders (normalized)
- Event: OrderCreated
- Read DB: DailyStats (denormalized aggregates)Pattern 2: Full Event Sourcing
Don't store current state.
Store all events.
Derive state from replaying events.
Events:
- AccountCreated
- MoneyDeposited
- MoneyWithdrawn
- FeeCharged
Current state = Replay all events from start
Benefits:
- Audit trail (know exactly what happened)
- Can "time travel" (what was state on date X?)
- Can rebuild read model (replay all events to new schema)Don't store current state.
Store all events.
Derive state from replaying events.
Events:
- AccountCreated
- MoneyDeposited
- MoneyWithdrawn
- FeeCharged
Current state = Replay all events from start
Benefits:
- Audit trail (know exactly what happened)
- Can "time travel" (what was state on date X?)
- Can rebuild read model (replay all events to new schema)Advanced CQRS + Event Sourcing Prompt Template
Build a CQRS + Event Sourcing system for [USE CASE]:
## Use Case
What are you building?
Example: Financial account system (deposits, withdrawals, transfers)
Read/write ratio:
- Reads per second: [X]
- Writes per second: [Y]
Example: 1000 reads/sec, 100 writes/sec (10:1 ratio)
If ratio < 5:1 โ Don't use CQRS (single database is fine)
If ratio > 5:1 โ CQRS might help
## Write Model (Command Side)
What commands (writes) does the system support?
1. CreateAccount
- Input: account_holder, initial_balance
- Validation: Name not empty, balance >= 0
- Side effect: Emit AccountCreated event
2. Deposit
- Input: account_id, amount
- Validation: amount > 0, account exists
- Side effect: Emit MoneyDeposited event
3. Withdraw
- Input: account_id, amount
- Validation: amount > 0, account exists, balance >= amount
- Side effect: Emit MoneyWithdrawn event or WithdrawalFailed event
4. Transfer
- Input: from_account_id, to_account_id, amount
- Validation: Both accounts exist, from has balance, amount > 0
- Side effect: Emit MoneyTransferred event
## Event Catalog
Define all events:
1. AccountCreated
- Fields: account_id, account_holder, initial_balance, created_at
- Triggering command: CreateAccount
- Version: 1
2. MoneyDeposited
- Fields: account_id, amount, deposited_at, transaction_id
- Triggering command: Deposit
- Version: 1
3. MoneyWithdrawn
- Fields: account_id, amount, withdrawn_at, transaction_id
- Triggering command: Withdraw
- Version: 1
4. WithdrawalFailed
- Fields: account_id, amount, reason, attempted_at, transaction_id
- Triggering command: Withdraw (but validation failed)
- Version: 1
5. MoneyTransferred
- Fields: from_account, to_account, amount, transferred_at, transaction_id
- Triggering command: Transfer
- Version: 1
6. MoneyTransferFailed
- Fields: from_account, to_account, amount, reason, attempted_at
- Version: 1
7. FeeCharged
- Fields: account_id, fee_amount, reason, charged_at
- Triggering command: (System/scheduled job)
- Version: 1
## Event Store
Store all events:
```sql
CREATE TABLE event_store (
event_id UUID PRIMARY KEY,
event_type VARCHAR(100), -- AccountCreated, MoneyDeposited, etc
aggregate_id UUID, -- account_id (partition key)
version INT, -- Which version of the event
payload JSON, -- Full event data
timestamp TIMESTAMP,
created_at TIMESTAMP,
-- Ensure ordering per aggregate
UNIQUE(aggregate_id, version)
);
-- Index for querying events
CREATE INDEX idx_aggregate ON event_store(aggregate_id, version);Build a CQRS + Event Sourcing system for [USE CASE]:
## Use Case
What are you building?
Example: Financial account system (deposits, withdrawals, transfers)
Read/write ratio:
- Reads per second: [X]
- Writes per second: [Y]
Example: 1000 reads/sec, 100 writes/sec (10:1 ratio)
If ratio < 5:1 โ Don't use CQRS (single database is fine)
If ratio > 5:1 โ CQRS might help
## Write Model (Command Side)
What commands (writes) does the system support?
1. CreateAccount
- Input: account_holder, initial_balance
- Validation: Name not empty, balance >= 0
- Side effect: Emit AccountCreated event
2. Deposit
- Input: account_id, amount
- Validation: amount > 0, account exists
- Side effect: Emit MoneyDeposited event
3. Withdraw
- Input: account_id, amount
- Validation: amount > 0, account exists, balance >= amount
- Side effect: Emit MoneyWithdrawn event or WithdrawalFailed event
4. Transfer
- Input: from_account_id, to_account_id, amount
- Validation: Both accounts exist, from has balance, amount > 0
- Side effect: Emit MoneyTransferred event
## Event Catalog
Define all events:
1. AccountCreated
- Fields: account_id, account_holder, initial_balance, created_at
- Triggering command: CreateAccount
- Version: 1
2. MoneyDeposited
- Fields: account_id, amount, deposited_at, transaction_id
- Triggering command: Deposit
- Version: 1
3. MoneyWithdrawn
- Fields: account_id, amount, withdrawn_at, transaction_id
- Triggering command: Withdraw
- Version: 1
4. WithdrawalFailed
- Fields: account_id, amount, reason, attempted_at, transaction_id
- Triggering command: Withdraw (but validation failed)
- Version: 1
5. MoneyTransferred
- Fields: from_account, to_account, amount, transferred_at, transaction_id
- Triggering command: Transfer
- Version: 1
6. MoneyTransferFailed
- Fields: from_account, to_account, amount, reason, attempted_at
- Version: 1
7. FeeCharged
- Fields: account_id, fee_amount, reason, charged_at
- Triggering command: (System/scheduled job)
- Version: 1
## Event Store
Store all events:
```sql
CREATE TABLE event_store (
event_id UUID PRIMARY KEY,
event_type VARCHAR(100), -- AccountCreated, MoneyDeposited, etc
aggregate_id UUID, -- account_id (partition key)
version INT, -- Which version of the event
payload JSON, -- Full event data
timestamp TIMESTAMP,
created_at TIMESTAMP,
-- Ensure ordering per aggregate
UNIQUE(aggregate_id, version)
);
-- Index for querying events
CREATE INDEX idx_aggregate ON event_store(aggregate_id, version);Read Models
Design optimized read models for each query:
Read Model 1: Account Balance
Query: "What's the current balance of account 123?"
Read model table:
โโ account_id (indexed)
โโ current_balance
โโ last_updated_at
โโ transaction_count
Updated by: MoneyDeposited, MoneyWithdrawn events
Latency: 1 second behind (OK for balance)Query: "What's the current balance of account 123?"
Read model table:
โโ account_id (indexed)
โโ current_balance
โโ last_updated_at
โโ transaction_count
Updated by: MoneyDeposited, MoneyWithdrawn events
Latency: 1 second behind (OK for balance)Read Model 2: Transaction History
Query: "Show me last 100 transactions for account 123"
Read model table:
โโ account_id (indexed)
โโ transaction_id
โโ type (deposit, withdrawal, transfer)
โโ amount
โโ timestamp (indexed for sorting)
โโ description
Updated by: All transaction events
Latency: Instant (materialized view)Query: "Show me last 100 transactions for account 123"
Read model table:
โโ account_id (indexed)
โโ transaction_id
โโ type (deposit, withdrawal, transfer)
โโ amount
โโ timestamp (indexed for sorting)
โโ description
Updated by: All transaction events
Latency: Instant (materialized view)Read Model 3: Daily Statement
Query: "Show me daily summary for account 123 on date 2025-01-01"
Read model table:
โโ account_id
โโ date (indexed)
โโ opening_balance
โโ deposits_count, deposits_total
โโ withdrawals_count, withdrawals_total
โโ fees_count, fees_total
โโ closing_balance
Updated by: All transaction events (batch updated daily)
Latency: 1 day behind (acceptable)Query: "Show me daily summary for account 123 on date 2025-01-01"
Read model table:
โโ account_id
โโ date (indexed)
โโ opening_balance
โโ deposits_count, deposits_total
โโ withdrawals_count, withdrawals_total
โโ fees_count, fees_total
โโ closing_balance
Updated by: All transaction events (batch updated daily)
Latency: 1 day behind (acceptable)Synchronization (Write โ Read)
Events flow from write to read models:
Event emitted
โ
Event stored in event store (write DB)
โ
Event published to event stream (Kafka/RabbitMQ)
โ
Read model updater consumes event
โ
Update read model (separate database)
โ
Read model is now eventually consistent
(Usually < 1 second lag)Event emitted
โ
Event stored in event store (write DB)
โ
Event published to event stream (Kafka/RabbitMQ)
โ
Read model updater consumes event
โ
Update read model (separate database)
โ
Read model is now eventually consistent
(Usually < 1 second lag)Pseudo-code:
// When command executes
function deposit(accountId, amount) {
// 1. Update write model (event store)
const event = {
event_id: uuid(),
event_type: 'MoneyDeposited',
aggregate_id: accountId,
version: 5,
payload: { account_id: accountId, amount, deposited_at: now() }
};
eventStore.insert(event);
// 2. Publish to event stream
eventBus.publish('MoneyDeposited', event);
// 3. Return immediately (don't wait for read model update)
return { success: true, event_id: event.id };
}
// Separate process: Read model updater
eventBus.subscribe('MoneyDeposited', async (event) => {
const { account_id, amount } = event.payload;
// Update read model
db.query(
'UPDATE account_balance SET current_balance = current_balance + ?, last_updated = NOW() WHERE account_id = ?',
[amount, account_id]
);
});// When command executes
function deposit(accountId, amount) {
// 1. Update write model (event store)
const event = {
event_id: uuid(),
event_type: 'MoneyDeposited',
aggregate_id: accountId,
version: 5,
payload: { account_id: accountId, amount, deposited_at: now() }
};
eventStore.insert(event);
// 2. Publish to event stream
eventBus.publish('MoneyDeposited', event);
// 3. Return immediately (don't wait for read model update)
return { success: true, event_id: event.id };
}
// Separate process: Read model updater
eventBus.subscribe('MoneyDeposited', async (event) => {
const { account_id, amount } = event.payload;
// Update read model
db.query(
'UPDATE account_balance SET current_balance = current_balance + ?, last_updated = NOW() WHERE account_id = ?',
[amount, account_id]
);
});Handling Eventual Consistency
Write model: Instantly consistent Read models: Eventually consistent (1โ5 seconds lag)
Problem: User makes withdrawal, immediately checks balance, sees old balance.
Solution options:
- Accept it: User sees balance update in 1 second (OK for most)
- Optimistic UI: Show expected balance immediately
User: Withdraw $100 UI shows: Balance updated instantly (optimistic) Server confirms: Balance updated (1 second later) If server rejects: Revert balance in UI
- Strong consistency when needed:
User submits withdrawal Wait for event to propagate to read model (500ms) Then show updated balance Cost: Slightly slower writes (but stronger guarantee)
Rebuilding Read Models
One benefit of event sourcing: Rebuild read models anytime.
Want to change read model schema?
No problem:
1. Iterate through event store (from start)
2. Replay all events into new schema
3. Validate against old schema
4. Switch to new schema
Example:
Old read model: DailyStats
New read model: HourlyStats
Iterate events:
- Replay all MoneyDeposited, MoneyWithdrawn events
- Compute hourly aggregates
- New schema ready in minutes (not weeks)Want to change read model schema?
No problem:
1. Iterate through event store (from start)
2. Replay all events into new schema
3. Validate against old schema
4. Switch to new schema
Example:
Old read model: DailyStats
New read model: HourlyStats
Iterate events:
- Replay all MoneyDeposited, MoneyWithdrawn events
- Compute hourly aggregates
- New schema ready in minutes (not weeks)Return: Complete CQRS + Event Sourcing System
Provide:
- Event store schema
- Event catalog (all events)
- Write model code (handle commands)
- Event publishing code
- Read model schemas (multiple views)
- Read model update code
- Consistency handling (eventual vs strong)
- Rebuild process (replay events)
- Testing (event replay, time travel)
- Monitoring (lag, errors, latency)
---
## Real Example: Bank Account (Full CQRS)
**Scenario:** User checks balance, deposits $100, transfers $50
**Write Model (Event Store):**
---
## Real Example: Bank Account (Full CQRS)
**Scenario:** User checks balance, deposits $100, transfers $50
**Write Model (Event Store):**
Event 1: AccountCreated โโ account_id: acc_123 โโ owner: Alice โโ created_at: 2025โ01โ01T10:00:00Z โโ version: 1
Event 2: MoneyDeposited โโ account_id: acc_123 โโ amount: $500 (initial deposit) โโ deposited_at: 2025โ01โ01T10:05:00Z โโ version: 2
Event 3: MoneyDeposited โโ account_id: acc_123 โโ amount: $100 (new deposit) โโ deposited_at: 2025โ01โ01T10:10:00Z โโ version: 3
Event 4: MoneyTransferred โโ from_account: acc_123 โโ to_account: acc_456 โโ amount: $50 โโ transferred_at: 2025โ01โ01T10:15:00Z โโ version: 4
**Read Model: Account Balance (denormalized)****Read Model: Account Balance (denormalized)**account_id: acc_123 current_balance: $550 (500 + 100โ50) last_updated: 2025โ01โ01T10:15:05Z transaction_count: 3
**Read Model: Transaction History****Read Model: Transaction History**Transactions:
- MoneyDeposited: $500 on 2025โ01โ01 10:05:00
- MoneyDeposited: $100 on 2025โ01โ01 10:10:00
- MoneyTransferred: $50 on 2025โ01โ01 10:15:00
**Key insight:**
- Write model: Pure event store (immutable log)
- Read models: Derived from events (updatable, denormalized)
- Can rebuild read models by replaying events
- Can time-travel: "What was balance on 10:05?". Answer: $500
---
## Your Next Step: Audit Your System
For your system:
1. **Read/write ratio:** [Reads per sec] / [Writes per sec]
If < 5:1 โ Don't use CQRS
If > 5:1 โ Consider CQRS
2. **Read complexity:** [How complex is your heaviest query?]
If simple (< 100ms) โ Don't need CQRS
If complex (> 1 second) โ CQRS helps
3. **Consistency requirement:** [Do you need instant consistency?]
If yes โ Traditional database
If no (1-5 sec lag OK) โ CQRS works
4. **Scale target:** [How many queries/second?]
If < 1000 โ Traditional database fine
If > 10,000 โ CQRS needed
Based on answers:
- If low ratio + simple queries โ Stick with traditional
- If high ratio + complex queries โ Implement CQRS
---**Key insight:**
- Write model: Pure event store (immutable log)
- Read models: Derived from events (updatable, denormalized)
- Can rebuild read models by replaying events
- Can time-travel: "What was balance on 10:05?". Answer: $500
---
## Your Next Step: Audit Your System
For your system:
1. **Read/write ratio:** [Reads per sec] / [Writes per sec]
If < 5:1 โ Don't use CQRS
If > 5:1 โ Consider CQRS
2. **Read complexity:** [How complex is your heaviest query?]
If simple (< 100ms) โ Don't need CQRS
If complex (> 1 second) โ CQRS helps
3. **Consistency requirement:** [Do you need instant consistency?]
If yes โ Traditional database
If no (1-5 sec lag OK) โ CQRS works
4. **Scale target:** [How many queries/second?]
If < 1000 โ Traditional database fine
If > 10,000 โ CQRS needed
Based on answers:
- If low ratio + simple queries โ Stick with traditional
- If high ratio + complex queries โ Implement CQRS
---