CQRS with Event-Driven Architecture
Mental model
CQRS separates write responsibility from read responsibility. Events provide one way to keep a separate read model updated.
Our E-Commerce Example
flowchart LR Client --> API["API Gateway"] API --> Command["Order Command"] Command --> Order["Order Service"] Order --> WriteDB[("Order DB")] Order --> Outbox["Outbox"] Outbox --> Kafka["Kafka"] Kafka --> Projection["Order Projection"] Projection --> ReadDB[("Order Read DB")] API --> Query["Order Query"] Query --> ReadDB
Write Flow
POST /orders
│
▼
CreateOrder
│
▼
Order Service
│
├── orders
└── outbox
│
▼
KafkaThe command side owns the authoritative business state.
Read Flow
Kafka
│
▼
Projection Consumer
│
▼
Order Read Model
│
▼
GET /orders/{id}The query side reads a model optimized for retrieval.
Why This Is Eventually Consistent
Consider:
T0 Order committed
T1 Kafka event published
T2 Projection consumes event
T3 Read model updatedBetween T0 and T3:
Write DB → "SHIPPED"
Read DB → "PROCESSING"This is a normal consequence of an asynchronously updated projection.
Product decision
You must decide whether the user experience can tolerate this delay.
For some operations, returning the freshly written command result is better than immediately reading the asynchronous projection.
Example
After:
POST /ordersreturn:
{
"orderId": "123",
"status": "CREATED"
}rather than immediately depending on the read model being updated.
Handling Duplicate Events
The projection consumer may receive:
OrderCreated(id=123)
OrderCreated(id=123)The projection must be idempotent.
One approach:
CREATE UNIQUE INDEX
idx_projection_event
ON processed_events(event_id);Or design the projection update so repeating the same event produces the same result.
This connects directly to:
Ordering
Suppose:
OrderCreated
OrderPaid
OrderShippedIf events affecting the same order are processed out of order, the projection could become incorrect.
Using a stable Kafka key such as:
kafkaTemplate.send(
"order-events",
orderId,
event
);helps keep events for an order in the same partition.
Remember:
Kafka ordering
Ordering is guaranteed within a partition, not globally across the topic.
CQRS + Outbox
The full chain becomes:
SAME DB TRANSACTION
┌─────────────────────┐
Create Order ────► │ orders + outbox │
└──────────┬──────────┘
│
▼
Kafka
│
▼
Projection
│
▼
Read ModelThis is a very useful production architecture because the event cannot be lost merely because the application crashed between the DB commit and event publication.
CQRS + Saga
CQRS can also coexist with Saga:
Order Command
│
▼
Order Service
│
▼
OrderCreated
│
▼
Saga
┌───┼──────────┐
▼ ▼ ▼
Inv Payment ShippingThe Saga coordinates the business workflow.
CQRS determines how command and query responsibilities are separated.
Outbox reliably moves local state changes into events.
Idempotency protects against duplicate processing.
These are different problems.
Remember the four patterns
CQRS
→ Separate reads from writes
Outbox
→ Keep DB change and event intent consistent
Saga
→ Coordinate distributed business workflow
Idempotency
→ Make retries/duplicates safeInterview Scenario
User creates an order and immediately calls GET /orders/{id}. The GET still says "not found." Is that a bug?
Not necessarily.
If the read model is asynchronously updated, the system is eventually consistent. But it may be a poor user experience.
Possible approaches:
- return the command result directly
- read from the authoritative store for that flow
- temporarily route the user’s read to the write side
- use a consistency mechanism appropriate to the product requirement
The important answer is:
Know where eventual consistency exists and make the product behavior deliberate.
When This Architecture Is Worth It
Good candidates:
- high read/write asymmetry
- complex read models
- large query workloads
- independent read scaling
- event-driven integrations
- audit/history requirements
Poor candidate:
Small CRUD application
10 users
simple reads
simple writesThe additional complexity would not buy us much.
Next
We’ll put Redis in front of some of these read-heavy paths and then examine the problems caching introduces: invalidation, stale data, cache stampede, concurrency, TTL, and eviction.