Problem Statement — what are we building and why?
In many real systems, reads and writes have fundamentally different shapes:
- Writes must enforce business invariants (e.g., you can’t oversell inventory, you can’t double-charge a card, you can’t follow yourself).
- Reads must be fast and flexible (feeds, dashboards, search, “my orders” pages), often requiring denormalized data.
CQRS (Command Query Responsibility Segregation) is a design pattern that separates the write model (commands) from the read model (queries) so each can be optimized and scaled independently. CQRS does not inherently require microservices, Kafka, or event sourcing, but it is frequently paired with:
- Event streaming / PubSub for propagating changes to read models.
- Event sourcing for an append-only source of truth.
- Transactional outbox to reliably publish events.
- Sagas to coordinate multi-service workflows.
This case study mirrors an interview: we’ll design a generic but concrete CQRS system for an e-commerce “Order” domain (place order, cancel order, view order history, order dashboard). The same architecture applies to ride-hailing, banking ledgers, social feeds, inventory, etc.
References for grounding:
- Microsoft CQRS pattern guidance: https://learn.microsoft.com/en-us/azure/architecture/patterns/cqrs
- Netflix Tudum CQRS evolution story (read-model distribution and freshness concerns): https://netflixtechblog.com/[netflix](/design/netflix/)-tudum-architecture-from-cqrs-with-[kafka](/systems/kafka/)-to-cqrs-with-raw-hollow-86d141b72e52
- Google Cloud Spanner + Change Streams as a CDC-to-projections pattern: https://cloud.google.com/blog/products/databases/event-driven-apps-with-cloud-spanner-change-streams
Requirements
Functional Requirements (user-facing features, 3-5 key ones)
Place Order (Command)
- Validate cart, pricing, inventory reservation, and payment authorization.
- Return an
order_idand a status.
Cancel Order (Command)
- Only allowed in certain states (e.g., before shipment).
- Triggers compensations (release inventory, void payment auth).
Get Order Details (Query)
- Fast read for order summary and line items.
List My Orders (Query)
- Paginated order history for a user.
Operations Dashboard (Query)
- Near-real-time metrics (orders per minute, failures by reason), filterable.
Non-Functional Requirements (latency <Xms, availability 99.99%, consistency model, durability)
Availability
- Command API: 99.95% (writes are harder; correctness matters)
- Query API: 99.99% (reads should stay up even if projections lag)
Latency (P99)
- Commands (place/cancel): < 200ms excluding third-party payment latency; with payment, target < 800ms.
- Queries (order details / list): < 50ms from cache, < 150ms from read DB.
Consistency model
- Write model: strong consistency within an Order aggregate (single order’s state transitions are linearizable).
- Read model: eventual consistency (seconds-level lag acceptable), with an option for read-your-writes for critical screens.
Durability
- All accepted commands must survive machine loss and restarts.
- Events must be durably recorded and replayable (at least for a bounded window).
Security & compliance (typical follow-up)
- PII encryption at rest, TLS in transit.
- Audit trail for state transitions.
Capacity Estimation (back-of-envelope calculations)
We’ll pick concrete numbers (call out that they’re estimates, as you would in interviews).
Traffic assumptions
- DAU: 50M
- Orders per DAU per day: 0.2 (1 order per 5 days)
- Orders/day = 50M × 0.2 = 10M orders/day
- Writes per order: ~3 commands on average (place, updates, cancel/return events)
- Write commands/day ≈ 10M × 3 = 30M commands/day
Using cheat sheet: 1 day ≈ 10^5 seconds.
- Average command QPS: 30M / 86,400 ≈ 347 QPS
- Peak QPS: 3× average ≈ 1,050 QPS
Reads are usually much higher:
- Order detail views: 5 per order (user checks status, emails, support)
- 10M × 5 = 50M detail reads/day
- Order list views: 1 per DAU/day = 50M list reads/day
Total reads/day ≈ 100M.
- Average read QPS: 100M / 86,400 ≈ 1,157 QPS
- Peak read QPS: 3× ≈ 3,470 QPS
This is already read-heavy, and many real systems are 10–100× more read-heavy.
Storage estimates
Write DB (transactional)
Per order (normalized):
- Order row: ~500 bytes
- Line items: average 3 items × 300 bytes = 900 bytes
- Payment/inventory references: ~300 bytes
Total per order ≈ 1.7KB (excluding indexes; round to 2KB with overhead).
- Storage/day: 10M × 2KB = 20GB/day
- Storage/year: 20GB × 365 ≈ 7.3TB/year (before replication)
Events / outbox
Assume 5 events/order average, 500 bytes/event (JSON/Avro/Proto payload + metadata):
- Events/day: 10M × 5 = 50M events/day
- Storage/day: 50M × 500B = 25GB/day
- Storage/year: ~9TB/year
Read models
Read models are denormalized; assume ~3KB/order for summaries + indexes:
- 10M × 3KB = 30GB/day
- ~11TB/year
Bandwidth (rough)
- Read response size average 2KB; peak read QPS 3,470
- 3,470 × 2KB ≈ 6.9MB/s (not huge; caches/CDN reduce further)
Cache sizing
If 5% of orders are “hot” (recent) and frequently accessed:
- Hot orders/day: 10M × 5% = 500k
- Cache entry 2KB + overhead; assume 4KB
- Cache size ~ 500k × 4KB ≈ 2GB
Add headroom for lists, user order lists, and metadata: plan 20–50GB Redis for a region.
High-Level Design with architecture diagram (mermaid)
flowchart LR
subgraph Clients
W[Web/Mobile]
OPS[Ops Dashboard]
end
W --> CDN[CDN/Edge Cache]
CDN --> GW[API Gateway + Auth + Rate Limiter]
OPS --> GW
subgraph CommandSide[Command Side]
CMD[Command API / Order Service]
WDB[(Write DB: Postgres/Spanner)]
OUT[(Outbox Table)]
end
subgraph Eventing[Eventing / CDC]
RELAY[Outbox Relay / CDC Connector]
BUS[(Kafka / PubSub Topics)]
DLQ[(Dead Letter Queue)]
end
subgraph Projections[Read Model Builders]
PROJ1[OrderSummary Projector]
PROJ2[UserOrders Projector]
PROJ3[OpsMetrics Projector]
IDEMP[(Idempotency/Dedupe Store)]
end
subgraph QuerySide[Query Side]
QAPI[Query API]
CACHE[(Redis Cache)]
RDB1[(Read DB: OrderSummary KV/Doc)]
RDB2[(Read DB: UserOrders Wide-Column)]
RDB3[(Read DB: Analytics/OLAP)]
end
GW --> CMD
CMD --> WDB
CMD --> OUT
OUT --> RELAY --> BUS
BUS --> PROJ1 --> RDB1
BUS --> PROJ2 --> RDB2
BUS --> PROJ3 --> RDB3
PROJ1 --> CACHE
BUS -->|bad events| DLQ
PROJ1 --> IDEMP
PROJ2 --> IDEMP
GW --> QAPI
QAPI --> CACHE
QAPI --> RDB1
QAPI --> RDB2
QAPI --> RDB3
Key idea: Commands write to a strongly consistent store, then changes propagate via events/CDC to projection workers that build one or more read models optimized for queries.
Detailed Design for each component
1) API Gateway / BFF
Responsibilities:
- Auth (JWT/OAuth), request validation
- Rate limiting (token bucket) per user/IP
- Routing:
/commands/*to Command API,/queries/*to Query API - Optional: response caching for safe GETs
Why it matters in CQRS:
- It enforces the separation at the edge: commands and queries have different SLAs and scaling.
2) Command API (Order Service)
Responsibilities:
- Validate command intent and business rules.
- Execute state changes atomically.
- Emit domain events reliably.
Implementation choices:
- Stateless app servers; scale horizontally.
- Use single DB transaction per command.
Command handling flow (PlaceOrder):
- Validate idempotency key.
- Validate user/cart.
- Create order row + line items.
- Insert outbox event(s) in same transaction.
- Commit.
- Return
order_idandorder_version.
3) Write DB
Default: SQL (Postgres/MySQL) or Spanner
Justification:
- Commands are where invariants live; SQL gives:
- transactions
- constraints
- uniqueness
- foreign keys
- familiar correctness semantics
If global multi-region is required with strong consistency, Spanner-like systems plus Change Streams can replace custom CDC.
4) Transactional Outbox + Relay (CDC)
Problem it solves:
- Without outbox, you can get: “DB commit succeeded, event publish failed.”
Outbox pattern:
- In the same transaction as the write, insert an outbox row:
event_id,aggregate_id,type,payload,created_at
- Relay process polls (or uses logical replication) and publishes to Kafka/PubSub.
Reliability:
- Relay uses at-least-once publish.
- Consumers must be idempotent.
5) Event Bus (Kafka / PubSub)
Why:
- Fanout to multiple projections (order summary, user history, ops metrics, notifications).
Design notes interviewers probe:
- Partitioning key:
aggregate_id(order_id) to preserve per-order ordering. - Delivery: at-least-once; duplicates possible.
- Retention: keep 7–30 days to allow replays/backfills.
6) Projection workers
Responsibilities:
- Consume events.
- Update read models.
- Maintain idempotency and ordering guarantees.
Projection correctness rules:
- Idempotent application: store processed
event_idor(aggregate_id, version). - Monotonic versioning: each order event increments
order_version. - DLQ for poison pills: invalid payloads, schema mismatch.
7) Read DB(s)
CQRS encourages polyglot persistence:
OrderSummary store (RDB1)
- Query pattern:
GetOrder(order_id) - Good fit: key-value/document (DynamoDB/Cassandra/DocDB) or even Postgres read replica at smaller scale.
- Query pattern:
UserOrders store (RDB2)
- Query pattern:
ListOrders(user_id, cursor, limit)sorted by time. - Good fit: wide-column (Cassandra/DynamoDB) with partition by user_id.
- Query pattern:
OpsMetrics store (RDB3)
- Query pattern: time series aggregates.
- Good fit: OLAP (BigQuery/Snowflake), or time-series DB.
8) Query API
Responsibilities:
- Serve read traffic from caches and read models.
- Pagination, filtering, shaping.
- Optional “freshness” semantics (read-your-writes).
Scaling:
- Stateless; scale independently from command side.
Database Design with schema
Choice of SQL vs NoSQL with JUSTIFICATION
Write side: SQL (Postgres/Spanner)
- We need atomic multi-row updates (order + items + outbox).
- We need invariants (no duplicate order numbers, valid state transitions).
- SQL makes correctness easier and reduces application-level concurrency bugs.
Read side: NoSQL / specialized stores
- Read access patterns are predictable and can be denormalized.
- We want low-latency reads and horizontal scaling.
- Eventual consistency is acceptable for most views.
Write DB schema (Postgres-style)
orders
order_id(UUID, PK)user_id(UUID, indexed)status(enum: PENDING, CONFIRMED, CANCELED, SHIPPED)total_amount_cents(int)currency(char(3))order_version(bigint) — increments per state changecreated_at,updated_at
Indexes:
- PK on
order_id (user_id, created_at DESC)for support/admin queries (not the main read path)
order_items
order_id(FK)item_id(UUID)sku(string)qty(int)unit_price_cents(int)- PK:
(order_id, item_id)
command_dedup
idempotency_key(string, PK)user_id(UUID)command_name(string)request_hash(optional)order_id(UUID)created_at
Purpose:
- Prevent duplicate PlaceOrder due to retries/timeouts.
outbox_events
event_id(UUID, PK)aggregate_type(e.g., “Order”)aggregate_id(UUID)aggregate_version(bigint)event_type(string)payload(jsonb / bytes)created_atpublished_at(nullable)
Indexing strategy:
- Index on
(published_at, created_at)for relay scanning. - Optional partial index
WHERE published_at IS NULL.
Read DB schema
RDB1: order_summary (document/kv)
Key: order_id
Value:
{
"order_id": "...",
"user_id": "...",
"status": "SHIPPED",
"total": {"amount_cents": 2599, "currency": "USD"},
"items": [{"sku": "abc", "qty": 2, "price_cents": 999}],
"shipping": {"carrier": "...", "tracking": "..."},
"order_version": 17,
"updated_at": "..."
}
Indexing:
- Primary key lookup only. If you need secondary attributes (status filtering), maintain separate indexes or a separate read model.
RDB2: user_orders_by_time (wide-column)
Partition/shard key selection:
- Partition key:
user_id - Clustering key:
created_at DESC, order_id
Why:
- The dominant query is “orders for a user sorted by time.”
- This avoids scatter-gather.
Hot partition risk:
- Some users may have extremely high order volume (rare). If needed:
- bucket by month: partition key
(user_id, yyyy_mm) - or add a small bucket
(user_id, bucket)for heavy users.
- bucket by month: partition key
RDB3: orders_metrics_minute
- Key:
(minute_ts, metric_name, dimension) - Value: counts/sums
Built from events; used for dashboards.
API Design with REST endpoints, request/response schemas
We’ll expose REST externally (web/mobile), and optionally use gRPC internally.
Commands (REST)
POST /commands/orders:place
Headers:
Idempotency-Key: <uuid>
Request:
{
"user_id": "u_123",
"cart_id": "c_456",
"payment_method_id": "pm_789",
"shipping_address_id": "addr_1"
}
Response (202 or 200 depending on sync/async payment):
{
"order_id": "o_abc",
"status": "PENDING",
"order_version": 1,
"accepted_at": "2026-08-14T12:00:00Z"
}
Notes:
- Returning
order_versionenables read-your-writes strategies on the query side.
POST /commands/orders/{order_id}:cancel
Headers:
Idempotency-Key
Request:
{ "reason": "USER_REQUEST" }
Response:
{
"order_id": "o_abc",
"status": "CANCELED",
"order_version": 5
}
Queries (REST)
GET /queries/orders/{order_id}
Response:
{
"order": { /* order_summary document */ },
"freshness": {
"read_model_version": 17,
"source": "cache|read_db"
}
}
Optional read-your-writes:
- Client may pass
?min_version=17. - Query API can wait briefly or fallback (discussed below).
GET /queries/users/{user_id}/orders?cursor=...&limit=20
Response:
{
"orders": [{"order_id":"...","status":"...","created_at":"..."}],
"next_cursor": "..."
}
Internal (gRPC, optional)
OrderCommandService.PlaceOrder(PlaceOrderRequest) returns (PlaceOrderResponse)OrderQueryService.GetOrder(GetOrderRequest) returns (GetOrderResponse)
gRPC is common internally for lower overhead and stronger contracts, but REST is usually sufficient for interviews.
Deep Dive 1: How to handle hot partitions / celebrity problem
Even though we’re designing “orders,” the same hotspot issue appears everywhere:
- A single key gets disproportionate traffic.
- A single partition in Kafka or Cassandra becomes overloaded.
Where hotspots show up in CQRS
Event bus partition hotspots
- If partition key is
order_id, load is spread well. - If you choose a coarse key (e.g.,
user_id), a whale user could dominate.
- If partition key is
Read model hotspots
user_orders_by_timepartitioned byuser_idcan hotspot for a whale.
Cache hotspots
- A single order being checked repeatedly (support incident, viral issue).
Mitigations
Choose partition keys aligned with uniformity
- For event ordering, use
aggregate_id(order_id). Orders are typically uniformly distributed.
- For event ordering, use
Bucket heavy partitions
- For
user_orders_by_time, use(user_id, yyyy_mm)or(user_id, bucket).
- For
Hybrid materialization (Instagram-style analogy)
- Instagram feed systems often use hybrid fanout to handle celebrity accounts.
- CQRS analog: don’t precompute expensive views for whales; compute-on-read or maintain a specialized read model.
Load shedding and rate limits
- For pathological keys, apply per-key rate limits at Query API.
Cache stampede protection
- Use request coalescing (single flight) and short TTL jitter.
Deep Dive 2: Consistency vs availability trade-offs specific to CQRS
CQRS almost always implies eventual consistency on the read side. Interviewers will push: “What does the user see right after placing an order?”
Baseline behavior
- Command returns success after write transaction commits.
- Read model updates asynchronously (events → projections).
- User might see the old state for a short window.
Options for “read-your-writes”
Version-based waiting (recommended)
- Command returns
order_version = v. - Query request includes
min_version=v. - Query API checks read model version; if behind, it can:
- wait up to e.g. 200–500ms (long-poll)
- or return
202 Processingwith retry-after.
- Command returns
Fallback to write DB (selective)
- If read model is behind, query write DB for that single order.
- Pros: strong freshness
- Cons: couples query path to write DB; can overload it at scale.
Sticky reads / session affinity
- Route the user to a region where projections have caught up.
- Works better in single-region or with ordered replication.
Write-through into a “hot read store”
- On command commit, also synchronously update a small read store (or cache) for that order.
- Pros: immediate reads for that order
- Cons: more write latency and complexity; must still converge with projections.
CAP-style framing (what to say in interviews)
- Command side prioritizes consistency (within aggregate) and durability.
- Query side prioritizes availability and latency, accepting bounded staleness.
Netflix’s Tudum post is a good real-world example of teams evolving CQRS read distribution to improve freshness/performance characteristics for their use case.
Deep Dive 3: Caching strategy with invalidation approach
CQRS makes caching easier because the query side is isolated and often immutable-ish between updates.
Recommended: cache-aside on Query API
Read flow:
- Query API checks Redis:
GET order:{order_id} - On miss, read from RDB1 (order_summary)
- Populate cache with TTL (e.g., 30–120s) + jitter
Write flow:
- Do not update cache directly from Command API (keeps separation).
- Instead, projection worker updates the read DB and then either:
- Invalidate cache keys (
DEL order:{id}) - or refresh cache with the new document
- Invalidate cache keys (
Refreshing is often better for hot keys; invalidation is simpler.
Invalidation correctness
Because events can be duplicated or reordered across partitions, cache updates must be guarded:
- Include
order_versionin cached value. - Only overwrite if incoming version is newer.
Stampede and cold start
- Use single-flight locks (
SETNX lock:order:{id}) to prevent thundering herd. - Prewarm cache for the most recent orders if needed.
Scalability & Performance section
Scaling reads independently
- Query API scales horizontally behind a load balancer.
- Read DBs scale based on their access patterns:
- KV/doc store for direct lookups
- wide-column for timeline-like queries
- search/OLAP for analytics
Scaling writes independently
- Command API scales horizontally.
- Write DB scales via:
- vertical scaling + read replicas (though commands mostly write)
- partitioning/sharding by
order_idif necessary - or moving to distributed SQL (Spanner) for global scale.
Eventing throughput
- Peak commands ~1k QPS, events maybe 5k QPS; modest.
- But the architecture holds when this grows by 100×:
- Kafka topics partitioned by aggregate_id
- projection consumers scaled by partition count
Failure modes and resilience
Projection lag
- Symptoms: stale reads
- Mitigation: consumer autoscaling, backpressure, monitoring lag per partition
Poison pill event
- Mitigation: DLQ + alerting + replay tooling
Outbox relay stuck
- Mitigation: multiple relay instances with leader election; metrics on unpublished outbox rows
Read DB corruption due to buggy projector
- Mitigation: replay/backfill pipeline, immutable event log retention, canary projector
Monitoring to include:
- Command latency + error rates
- DB commit latency
- Outbox backlog
- Kafka consumer lag
- Projection success/failure counts
- Read cache hit rate
Trade-offs and Alternatives — what would you do differently for different constraints?
1) “Do we really need CQRS?”
If:
- reads and writes are similar
- traffic is modest
- team wants simplicity
Then a single relational DB with normalized schema + read replicas + caching may be better.
CQRS adds complexity:
- multiple datastores
- eventual consistency
- replay/backfill tooling
- schema evolution challenges
2) CQRS without Kafka (CDC-based)
Instead of application-emitted events:
- Use DB-level CDC (e.g., Spanner Change Streams, Debezium on Postgres)
- Pros: fewer code paths; avoids “forgot to publish event”
- Cons: event semantics become “row changes,” not domain events; harder to express intent
3) Event Sourcing vs state-stored write model
State-stored (what we designed):
- Store current state in SQL tables.
- Emit domain events for projections.
Event sourcing:
- Store only append-only events as the source of truth.
- Rebuild state by replay (with snapshots).
Event sourcing pros:
- perfect audit trail
- easy replay and temporal queries (“what did we know then?”)
Cons:
- harder querying on write side
- event schema evolution becomes critical
- operational complexity (snapshotting, rebuilding)
4) Exactly-once processing vs at-least-once
- Exactly-once end-to-end is extremely difficult across distributed boundaries.
- Design for at-least-once with:
- idempotent consumers
- dedupe store
- version checks
5) Multi-region
Options:
- Single-region write, multi-region read models (simpler)
- Multi-region writes require distributed transactions (Spanner-like) or careful partitioning by region
Interviewer follow-ups (and how this design answers them)
Where do you draw aggregate boundaries?
Orderis an aggregate; invariants enforced per order.- Avoid cross-order invariants in a single transaction; use sagas if needed.
How do you guarantee idempotency for commands?
Idempotency-Key+command_deduptable.- Return same
order_idfor retries.
How do you publish events reliably?
- Transactional outbox + relay.
How do you handle read-your-writes?
- Version-based waiting or selective fallback to write DB.
How do you rebuild projections?
- Replay from Kafka retention window or from stored outbox/events table.
- Maintain backfill jobs and versioned projector code.
How do you evolve event schema safely?
- Version events (
event_type+ schema version). - Backward-compatible changes; schema registry; upcasters.
- Version events (
How do you handle hot partitions?
- Proper partition keys, bucketing, hybrid materialization.
Summary
This CQRS design:
- Keeps writes correct using a transactional command model (SQL + invariants).
- Keeps reads fast and scalable using purpose-built read models (KV/doc + wide-column + OLAP) and caching.
- Propagates changes reliably using transactional outbox + event bus + idempotent projections.
- Embraces eventual consistency on the read side while offering pragmatic read-your-writes techniques.
CQRS is powerful when read and write concerns diverge—but it’s also an architectural commitment. In interviews, the strongest answers highlight not only the architecture, but also the failure modes (duplicates, reordering, projection bugs, hot keys) and the operational tooling required to keep the system healthy.