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


          Kafka

The 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 updated

Between 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 /orders

return:

{
  "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:

Idempotency

Ordering

Suppose:

OrderCreated
OrderPaid
OrderShipped

If 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 Model

This 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   Shipping

The 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 safe

Interview 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 writes

The additional complexity would not buy us much.

Next

Caching and Redis

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.