CQRS and read/write separation
What is CQRS
CQRS (Command Query Responsibility Segregation) is an architectural approach that separates the data model and the components responsible for writing (commands) and reading (queries).
The idea: the state change process is optimized for valid invariants and transactions, and reading for fast, targeted projections and scaling.
key> Commands change state and return the result of the operation. Requests are only read and have no side effects.
Why do you need it
Read performance: materialized projections for specific scenarios (tapes, reports, catalogs).
Critical path stability: recording isolated from "heavy" joins and aggregates.
Freedom of storage selection: OLTP for writing, OLAP/cache/search engines for reading.
Accelerated evolution: Add new views without the risk of "breaking" transactions.
Observability and auditing (especially in conjunction with Event Sourcing): it is easier to recover and replay the state.
When to apply (and when not)
Suitable if:- Readings with different data slices and complex aggregation prevail.
- The critical recording path must be subtle and predictable.
- Different SLO/SLAs are needed for reading and writing.
- Isolation of domain write logic from analytical/search needs is required.
- The domain is simple, the load is low; CRUD copes.
- Strong consistency between read and write is mandatory for all scenarios.
- The team is inexperienced and the operational complexity is unacceptable.
Basic concepts
Command-Intends to change state ('CreateOrder', 'CapturePayment'). Checks invariants.
Query-Retrieves data ('GetOrderById', 'ListUserTransactions'). No side effects.
Record model: aggregates/invariants/transactions; storage - relational/key-value/event log.
Read (projection) model: materialized tables/indexes/cache, synchronized asynchronously.
Consistency: Often eventual between recording and reading; critical paths - through direct reading from the write model.
Architecture (skeleton)
1. Write-service: accepts commands, validates invariants, captures changes (database or events).
2. Outbox/CDC: guaranteed publication of the fact of changes.
3. Projection processors: Listen to events/CDC and update read models.
4. Read-service: serves queries from materialized views/caches/searches.
5. Sagas/orchestration: coordinate cross-aggregate processes.
6. Observability: projection lag, percentage of successful applications, DLQ.
Designing a Recording Model
Aggregates: clear transaction boundaries (for example, 'Order', 'Payment', 'UserBalance').
Invariants: formalize (monetary amounts ≥ 0, uniqueness, limits).
Commands are idempotent by key (for example, 'idempotency _ key').
Transactions are minimal in scope; external side effects - via outbox.
Command Example (Pseudo-JSON)
json
{
"command": "CapturePayment",
"payment_id": "pay_123",
"amount": 1000,
"currency": "EUR",
"idempotency_key": "k-789",
"trace_id": "t-abc"
}
Designing a Reading Model
Start from queries: what screens/reports are needed?
Denormalization is acceptable: read-model - "optimized cache."
Several projections for different tasks: search (OpenSearch), reports (columnar storage), cards (KV/Redis).
TTL and reassembly: projections must be able to recover from the source (event replay/snapshots).
Consistency and UX
Eventual consistency: the interface can display old data for a short time.
UX patterns: "data is updated...," optimistic UI, synchronization indicators, blocking dangerous actions until confirmed.
For operations that require strong consistency (for example, showing an accurate balance before writing off), read directly from the write model.
CQRS and Event Sourcing (optional)
Event Sourcing (ES) stores events, and the state of the aggregate is the result of their convolution.
The CQRS + ES bundle gives an ideal audit and easy reassembly of projections, but increases complexity.
Alternative: regular OLTP database + outbox/CDC → projections.
Replication: Outbox and CDC
Outbox (in one transaction): writing domain changes + writing an event to outbox; publisher delivers to the tire.
CDC: reading from the database log (Debezium, etc.) → transformation into domain events.
Warranties: by default at-least-once, consumers and projections must be idempotent.
Storage selection
Write: relational (PostgreSQL/MySQL) for transactions; KV/Document - where the invariants are simple.
Read:- KV/Redis - cards and quick key readings;
- Search (OpenSearch/Elasticsearch) - search/filters/facets;
- Column (ClickHouse/BigQuery) - reports;
- Cache on CDN - public directories/content.
Integration patterns
API layer: separate endpoints/services for 'commands' and 'queries'.
Idempotency: key of the operation in the header/body; storage of recent-keys with TTL.
Sagas/orchestration: timeouts, compensations, step repeatability.
Backpressure-Limits the parallelism of projection processors.
Observability
Write metrics: p95/99 command latencies, percentage of successful transactions, validation errors.
Read metrics: p95/99 requests, hit-rate cache, load on the search cluster.
Projection lag (time and messages), DLQ rate, deduplication percentage.
Tracing: 'trace _ id' goes through the → outbox command → the → query projection.
Safety and compliance
Rights separation: different scopes/roles for writing and reading; the principle of least privilege.
PII/PCI: minimize in projections; at-rest/in-flight encryption; masking.
Audit: Fix team, actor, outcome, 'trace _ id'; WORM archives for critical domains (payments, KYC).
Testing
Contract tests: for commands (errors, invariants) and queries (formats/filters).
Projection tests: submit a series of events/CDC and check the final read model.
Chaos/latency: injecting latency into projection processors; UX check at lag.
Replayability: reassembling projections on the stand from snapshots/log.
Migrations and evolution
New fields - additive in event/CDC; read models are rebuilt.
Double-write when redesigning circuits; hold old projections until switching.
Versioning: 'v1 '/' v2' events and endpoints, Sunset-plan.
Feature flags: introduction of new queries/projections along the canary.
Anti-patterns
CQRS "for the sake of fashion" in simple CRUD services.
Hard synchronous read-write dependency (kills isolation and persistence).
One index for all: mixing heterogeneous queries into one read-store.
Projections do not have idempotency → duplicates and discrepancies.
Non-recoverable projections (no replay/snapshots).
Examples of domains
Payments (online service)
Write: 'Authorize', 'Capture', 'Refund' in transactional database; outbox publishes' payment. '.
Read:- Redis "payment card" for UI;
- ClickHouse for reporting;
- OpenSearch to search for transactions.
- Critical path: authorization ≤ 800 ms p95; read consistency for UI - eventual (up to 2-3 s).
KYC
Write: commands to start/update status; PII storage in a secure database.
Read: lightweight projection of statuses without PII; PII is tightened pointwise if necessary.
Security: different scopes on reading status and accessing documents.
Balance Sheets (iGaming/Finance)
Write: 'UserBalance' aggregate with atomic increments/decrements; idempotent keys for surgery.
Read: cache for "quick balance"; for write-off - direct reading from write (strict consistency).
Saga: deposits/conclusions are coordinated by events, in case of failures - compensation.
Implementation checklist
- Aggregates and invariants of the write model are highlighted.
- Key queries are defined and projections designed for them.
- Outbox/CDC and idempotent projection processors are configured.
- There is a snapshot/replay plan.
- SLO: command latency, projection lag, read/write availability separately.
- Separated access rights and data encryption implemented.
- DLQ alerts/lag/deduplication failures.
- Tests: Contracts, Projections, Chaos, Replay.
FAQ
Is Event Sourcing mandatory for CQRS?
No, it isn't. You can build on a regular database + outbox/CDC.
How to deal with desynchronization?
Explicitly design UX, measure projection lag, let critical operations read from write.
Is it possible to keep both write and read in the same service?
Yes, physical separation is optional; a logical division of responsibilities is mandatory.
What about transactions between aggregates?
Through sagas and events; Avoid distributed transactions if possible.
Result
CQRS unties the hands: a thin, reliable write path with clear invariants and fast, targeted reads from materialized projections. This increases productivity, simplifies evolution, and makes the system more resilient to stress - if consistency, observability, and migrations are disciplined.