Separate read and write models for optimized query and command paths. Covers CQRS architecture, event sourcing integration, read model projections, eventual consistency trade-offs, and when CQRS is worth the complexity.
CQRS (Command Query Responsibility Segregation) separates the read and write models of a system, allowing each to evolve independently. It matters when data access patterns diverge: when writes are complex and frequent, but reads are numerous, varied, and performance-sensitive. Use CQRS when you need high throughput for updates, low-latency queries, or when query logic grows beyond what a single database schema can support. It becomes essential when your system’s domain logic is rich and your users expect fast, real-time views of data.
CQRS is built on two distinct models: the *command model* (also *write model*) and the *query model* (also *read model*). Commands are operations that change state—CreateUser, UpdateOrder, ProcessPayment. Queries are operations that retrieve data—GetUserDetails, ListOrdersForUser, GetOrderSummary.
Commands are sent to a command handler, which applies domain logic and updates the command model. The command model is typically a normalized, transactional database with strong consistency. The command model emits events as side effects of state changes.
Queries are served from a denormalized, optimized read model, often a separate database or data store. The read model is updated asynchronously by event processors that consume events from the command model’s event stream.
The system’s state is not derived from the command model alone; it is eventually consistent with respect to the read model.
Events are the glue between models. A command handler emits one or more events upon successful execution. For example, OrderCreated, OrderShipped, PaymentProcessed.
Events are stored in an event store, a log of all state changes, typically as append-only records. Each event includes:
eventId (UUID)eventType (e.g., UserUpdated)timestampaggregateId (e.g., order-123)data (JSON object with payload)A typical event might be:
{
"eventId": "e5a3b1f7-2d6c-4f1a-8e9b-7c6d5e4f3a2b",
"eventType": "OrderCreated",
"timestamp": "2025-04-05T10:30:45.123Z",
"aggregateId": "order-123",
"data": {
"customerId": "cust-456",
"totalAmount": 29.99,
"items": [
{ "productId": "prod-1", "quantity": 2, "price": 9.99 }
]
}
}
Event processors consume events and update the read model. A processor might be a long-running service or a batch job.
Commands are sent via an HTTP API, message queue, or RPC. The command handler validates input, applies business rules, and persists state.
Example command request:
POST /commands
Content-Type: application/json
{
"commandType": "CreateUser",
"payload": {
"name": "Alice Smith",
"email": "[email protected]",
"preferredLanguage": "en"
}
}
The command handler returns a response indicating success or failure:
{
"commandId": "cmd-789",
"status": "Accepted",
"timestamp": "2025-04-05T10:30:46.234Z",
"correlationId": "corr-abc123"
}
The correlationId links the command with its events and eventual query results. It is critical for tracing and debugging.
The read model is updated via event processors. Each processor listens to a subset of events and updates one or more tables or indexes.
For example, an OrderSummaryProcessor listens to OrderCreated, OrderShipped, and PaymentProcessed events. It maintains a order_summaries table with:
orderIdcustomerIdstatus (e.g., "Pending", "Shipped", "Delivered")totalAmountshippingAddressupdatedAtEach event triggers a UPDATE or INSERT into this table. The processor may be implemented as a service using a message queue.
Example event processing loop:
def process_events():
while True:
events = message_queue.consume(topic="events", batch_size=100)
for event in events:
handle_event(event)
def handle_event(event):
match event["eventType"]:
case "OrderCreated":
insert_order_summary(
orderId=event["aggregateId"],
customerId=event["data"]["customerId"],
totalAmount=event["data"]["totalAmount"],
status="Created",
updatedAt=event["timestamp"]
)
case "OrderShipped":
update_order_summary(
orderId=event["aggregateId"],
status="Shipped",
updatedAt=event["timestamp"]
)
case "PaymentProcessed":
update_order_summary(
orderId=event["aggregateId"],
status="Paid",
updatedAt=event["timestamp"]
)
Queries are served from the read model. A query such as GetOrderSummary might be:
GET /queries/order-summary?orderId=order-123
Response:
{
"orderId": "order-123",
"customerId": "cust-456",
"status": "Shipped",
"totalAmount": 29.99,
"shippingAddress": {
"line1": "123 Main St",
"city": "Downtown",
"zip": "90210"
},
"updatedAt": "2025-04-05T11:15:23.456Z"
}
The read model may be a PostgreSQL table, a document database like MongoDB, or a search engine like Elasticsearch. Queries are fast, often served from memory or in a cache.
The read model is not updated immediately. There can be a delay of seconds to hours between command execution and query availability. A user creates an order, sees a confirmation, but the order summary takes 30 seconds to appear.
This introduces eventual consistency. The system is correct over time, but not immediately consistent. Clients must expect that a query may return stale data.
Event processors may fail to process events. The most common failure is event duplication. A processor reads an event, processes it, but fails before acknowledging the message. The message queue re-delivers the event, leading to duplicate updates.
To prevent this, event processors use idempotent operations. The order_summaries table must handle duplicate INSERTs and UPDATEs without side effects.
For example, an UPDATE may be:
INSERT INTO order_summaries (orderId, status, updatedAt)
VALUES ('order-123', 'Shipped', '2025-04-05T11:15:23.456Z')
ON CONFLICT (orderId)
DO UPDATE SET status = EXCLUDED.status, updatedAt = EXCLUDED.updatedAt;
Events are not guaranteed to arrive in order. If the event stream is partitioned, events for the same aggregate may arrive out of sequence. A processor must handle this.
A PaymentProcessed event may arrive before OrderCreated for the same order. The processor must buffer events and process them in order, or use event versioning.
Events evolve over time. A UserUpdated event may change from:
{
"eventType": "UserUpdated",
"data": {
"userId": "u1",
"name": "Alice",
"email": "[email protected]"
}
}
to:
{
"eventType": "UserUpdated",
"data": {
"userId": "u1",
"name": "Alice",
"email": "[email protected]",
"lastLogin": "2025-04-05T10:00:00Z",
"preferences": {
"theme": "dark",
"timezone": "UTC"
}
}
}
Old processors may fail to parse new event structures. To handle this, use event versioning:
eventType includes version: UserUpdated.v1, UserUpdated.v2A query may expect a field that is not updated in the read model. For example, GetUserDetails queries users table but misses preferences from UserUpdated.v2. The result is incomplete or incorrect data.
The solution is to ensure that all relevant events are processed and that the read model is fully materialized.
Without correlationId, tracing command execution is difficult. When a query returns stale data, it’s hard to determine:
A request to GetOrderSummary may return data from 30 seconds ago. To diagnose, developers must:
correlationId in logs.Commands should be idempotent. A client may retry a command that fails. The command handler must handle duplicate commands.
For example, a client sends CreateUser with userId: u1. The server processes it, returns success. But the client gets a timeout. It retries the same command. The server must detect the duplicate and return the same result.
Idempotency is achieved by:
commandId as a primary key in the command logcommandId in the command modelA command handler may return a commandId and mark the command as processed in a command_log table:
CREATE TABLE command_log (
commandId VARCHAR(36) PRIMARY KEY,
commandType VARCHAR(100),
payload JSON,
status VARCHAR(20),
createdAt TIMESTAMP,
processedAt TIMESTAMP
);
CQRS systems are often deployed as a set of microservices. Each service handles a domain model.
Deployment uses standard tools: docker, kubectl, systemctl, git.
Example docker-compose.yml for a CQRS system:
version: '3.8'
services:
command-api:
image: command-api:latest
ports:
- "8080:80"
depends_on:
- postgres
- kafka
event-store:
image: event-store:latest
ports:
- "2113:2113"
volumes:
- eventstore-data:/data
query-api:
image: query-api:latest
ports:
- "8081:80"
depends_on:
- postgres
order-processor:
image: order-processor:latest
depends_on:
- kafka
- postgres
environment:
- KAFKA_BROKERS=event-store:2113
- DB_HOST=postgres
- BATCH_SIZE=50
postgres:
image: postgres:15
volumes:
- pgdata:/var/lib/postgresql/data
kafka:
image: confluentinc/cp-kafka:7.5
environment:
- KAFKA_BROKER_ID=1
- KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
- KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092
- KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
volumes:
eventstore-data:
pgdata:
The system uses kafka as the event bus. Commands are published to a commands topic. Events are published to an events topic.
A systemctl unit for order-processor:
[Unit]
Description=Order Processor Service
After=network.target
[Service]
Type=simple
User=app
ExecStart=/usr/local/bin/order-processor --batch-size=50 --kafka-brokers=kafka:9092
Restart=always
RestartSec=10s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
The query model can be cached using Redis or in-memory caches. A GetOrderSummary may first check Redis before querying PostgreSQL.
GET /queries/order-summary?orderId=order-123
If the result is not in cache, it queries the read model and stores it in Redis with a TTL of 5 minutes.
A query may be built from multiple sources. For example, GetUserWithOrders may:
users tableorders tableThis is a projection. Projections can be materialized or computed on-demand.
CQRS is often combined with event sourcing: the command model is built from an event stream. Every state change is recorded as an event.
This enables:
The system may expose two distinct APIs:
POST /commands for command operationsGET /queries for query operationsClients use different endpoints based on intent.
Commands can be:
CreateUser)Accepted, then polls or receives eventSynchronous commands are simpler but add latency. Asynchronous commands are better for high-throughput systems.
CQRS is not just a pattern—it is a system architecture. It requires careful handling of events, idempotent commands, and eventual consistency. The system breaks when events are lost, duplicated, or misordered. The read model becomes stale. Queries return outdated data. But when it works, it delivers high performance, scalability, and flexibility.
The core insight is this: separate what you write from what you read. And when you do, you gain the ability to evolve both models independently.
This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy