⏱️ Lectura: 13 min
An application that bills orders while also generating sales reports ends up fighting over the same database resources. That bottleneck has a technical name: CQRS. The solution is to separate the model that writes from the model that reads, each optimized for its own task.
📑 En este artículo
- TL;DR
- What CQRS Is and Why Separating Reads from Writes Matters
- How It Works: Commands, Queries, and the Data Flow
- Practical Examples: From Traditional CRUD to CQRS
- How to Get Started, Step by Step
- Real-World Use Cases
- Common Mistakes and Best Practices
- Comparison with Alternatives
- Going Deeper: Eventual Consistency and Snapshots
- Frequently Asked Questions
- References
The pattern separates two responsibilities that almost always live together in the same model: writing data with commands and reading it with queries. As an application grows, that mix starts costing performance, tangled code, and increasingly complex SQL queries.
TL;DR
- CQRS separates the write model (commands) from the read model (queries), each with its own schema.
- A command changes state and doesn’t return business data; a query reads data and never modifies anything.
- Event Sourcing stores every change as an immutable event instead of overwriting rows, and the state is rebuilt by replaying those events.
- The read view updates asynchronously, so there’s a window of eventual consistency that needs to be designed for, not ignored.
- MediatR in .NET, Axon Framework in Java, and EventStoreDB are the reference tools for implementing the pattern without building the mechanism from scratch.
- Separating commands from queries lets each side scale independently, adding more read replicas without touching the write path.
- CQRS without Event Sourcing is perfectly valid: just separating the read and write tables already solves the most common performance problem.
What CQRS Is and Why Separating Reads from Writes Matters
CQRS stands for Command Query Responsibility Segregation, a term coined by Greg Young and documented in detail by Martin Fowler on his technical blog. The core idea is simple. A command changes the system’s state; a query reads it. No operation should do both at once.
In most CRUD applications (Create, Read, Update, Delete) both operations share the same data model. A pedidos table serves both to insert a new purchase and to generate the monthly sales report. It works fine with low traffic, but when the report needs heavy joins on the same table that’s receiving thousands of writes per minute, both sides compete for locks and cache.
CQRS attacks that problem by separating the write model from the read model. Each one can live in a different database engine, with a schema designed for its purpose: the write side normalized and transactional, the read side denormalized and fast to query.
How It Works: Commands, Queries, and the Data Flow
A command expresses an intent, like CrearPedido, CancelarSuscripcion, or ActualizarInventario. It’s processed against the write model, validates business rules, and persists the change. It never returns business data, at most an identifier or a status code.
A query requests data, like ObtenerPedidosDelCliente or ListarProductosConStock. It reads from the read model, which is usually a precomputed view or a denormalized table designed to respond fast, without touching business logic or validation rules.
Between the two models there’s a synchronization mechanism. When the write model confirms a change, it publishes an event (PedidoCreado, InventarioActualizado) that an independent process listens for and uses to update the corresponding read view.
flowchart TD
A["Client"] --> B["Command: CrearPedido"]
A --> C["Query: ObtenerPedidos"]
B --> D["Write model"]
D --> E["Event: PedidoCreado"]
E --> F["Projector"]
F --> G["Read model"]
C --> G
The diagram shows the key point of the pattern. The client never reads directly from the write model. Every query goes to the read model, which a process called the projector keeps updated based on the events emitted by the write side.
Practical Examples: From Traditional CRUD to CQRS
The first example is the typical starting point: a service that combines reading and writing in the same class, on the same table.
// traditional-crud.ts
class PedidoService {
async crear(clienteId: string, items: Item[]) {
const pedido = { id: crypto.randomUUID(), clienteId, items, estado: "creado" };
await db.query(
"INSERT INTO pedidos (id, cliente_id, items, estado) VALUES ($1, $2, $3, $4)",
[pedido.id, pedido.clienteId, JSON.stringify(pedido.items), pedido.estado]
);
return pedido.id;
}
async listarPorCliente(clienteId: string) {
const { rows } = await db.query(
"SELECT * FROM pedidos WHERE cliente_id = $1 ORDER BY creado_en DESC",
[clienteId]
);
return rows;
}
}
This code works fine until listarPorCliente starts including joins with products, shipping, and billing. Every new query competes for the same table that’s receiving INSERTs from checkout during peak hours.
The second example separates commands from queries into different classes, each with its own database connection.
// commands/create-order.ts
export class CrearPedidoHandler {
async handle(cmd: { clienteId: string; items: Item[] }) {
const pedido = new Pedido(cmd.clienteId, cmd.items);
await writeDb.query(
"INSERT INTO pedidos_write (id, cliente_id, items, estado) VALUES ($1, $2, $3, $4)",
[pedido.id, pedido.clienteId, JSON.stringify(pedido.items), "creado"]
);
await eventBus.publish("PedidoCreado", { pedidoId: pedido.id, clienteId: cmd.clienteId });
return pedido.id;
}
}
// queries/list-orders.ts
export class ListarPedidosHandler {
async handle(query: { clienteId: string }) {
const { rows } = await readDb.query(
"SELECT * FROM pedidos_read WHERE cliente_id = $1 ORDER BY creado_en DESC",
[query.clienteId]
);
return rows;
}
}
// projection/update-view.ts
eventBus.subscribe("PedidoCreado", async (evento) => {
await readDb.query(
"INSERT INTO pedidos_read (id, cliente_id, resumen, creado_en) VALUES ($1, $2, $3, now())",
[evento.pedidoId, evento.clienteId, "New order"]
);
});
The command writes to pedidos_write and publishes PedidoCreado. An independent subscriber updates pedidos_read, the table the application actually queries. If tomorrow the read view needs more columns, they’re added without touching the write model.
💡 Tip: you don’t need Event Sourcing to get started. Just separating the read and write tables, as in the previous example, already solves most of the performance problems without the complexity of storing events.
The third example adds Event Sourcing. Instead of storing the order’s final state, every event is stored and the state is rebuilt by replaying them in order.
// event-store.ts
type Evento = { tipo: string; payload: any; version: number };
class PedidoAggregate {
estado = "";
items: Item[] = [];
aplicar(evento: Evento) {
switch (evento.tipo) {
case "PedidoCreado":
this.estado = "creado";
this.items = evento.payload.items;
break;
case "PedidoConfirmado":
this.estado = "confirmado";
break;
case "PedidoCancelado":
this.estado = "cancelado";
break;
}
}
static reconstruir(eventos: Evento[]): PedidoAggregate {
const pedido = new PedidoAggregate();
for (const evento of eventos.sort((a, b) => a.version - b.version)) {
pedido.aplicar(evento);
}
return pedido;
}
}
To know an order’s current state, the system doesn’t read a row, it replays all its events ordered by version using reconstruir. The result is the same state, but with a complete history of every change, useful for auditing and for debugging business bugs months later.
How to Get Started, Step by Step
The simplest way to try CQRS is without Event Sourcing, with Node.js and two PostgreSQL connections, even if they point to the same instance at first.
- Install the base dependencies:
npm install pg eventemitter2. - Create two connection pools, one for writes (
writeDb) and one for reads (readDb). - Define the event bus with
eventemitter2:const eventBus = new EventEmitter2();. - Write the command handler that inserts into the write table and publishes the event, like the
CrearPedidoHandlerexample above. - Write the projector that listens for the event and updates the read table.
- Expose two separate HTTP routes:
POST /pedidosfor the command andGET /pedidos/:clienteIdfor the query, each one calling only its corresponding handler.
To verify the separation actually works, measure the response time of GET /pedidos/:clienteId while the command endpoint receives load from a tool like autocannon. If the read side doesn’t degrade while the write side is under pressure, the separation is doing its job.
If you already work in .NET, the most direct route is the MediatR library: it defines IRequest for commands and queries, and an IRequestHandler for each one. In Java, Axon Framework adds production-ready Event Sourcing and projections without writing the event bus by hand.
Real-World Use Cases
A banking system that records transfers as immutable events, without deleting or overwriting a transaction, is the classic example of Event Sourcing. An account’s balance is the sum of all its events, and the complete history serves regulatory audits.
In e-commerce, the product catalog is usually read thousands of times more than it’s written. Separating the read model allows that view to be cached aggressively and reserves write capacity for checkout, which does need strict transactions.
In logistics and inventory, every stock movement (inbound, outbound, adjustment) works well as an independent command. The read view can show available stock per warehouse without recalculating sums on every query, because the projector already keeps the total updated.
Real-time collaboration systems, like those using CRDTs to edit documents simultaneously, also apply variants of CQRS. Every edit is a command, and the view each user sees is a projection rebuilt from those commands.
Common Mistakes and Best Practices
The most common mistake is applying CQRS to a simple CRUD that never had performance problems. If a table receives a hundred writes a day and queries take milliseconds, separating models only adds infrastructure without real benefit.
The second mistake is underestimating eventual consistency. If a user creates an order and the frontend immediately redirects to the order list, the projection may not have updated yet and the new order won’t appear. The usual solution is to return the newly created order directly from the command’s own response, without relying on an immediate query to the read model.
⚠️ Watch out: never read from the write model to show data to the user “in case the projection hasn’t arrived yet.” That reintroduces the coupling that CQRS exists to eliminate and ends up with two read paths that need to be maintained.
A third gotcha shows up with message queues that guarantee at-least-once delivery: the same event can arrive duplicated at the projector. Projection handlers need to be idempotent, for example using INSERT ... ON CONFLICT DO NOTHING with the event id as the key, so processing the same event twice doesn’t duplicate data.
When using Event Sourcing, changing the shape of an already published event breaks the reconstruction of old aggregates. The standard practice is to version events (PedidoCreado_v2) and keep a handler that knows how to migrate old events to the new format.
Comparison with Alternatives
| Option | When to use it | Advantage | Limitation |
|---|---|---|---|
| Traditional CRUD | Small apps or apps with low, evenly balanced read and write traffic | Simplicity, a single model to maintain | Doesn’t scale well when reads and writes compete for the same resources |
| CQRS without Event Sourcing | Queries need a different schema than writes, but a complete history isn’t necessary | Each model is optimized and scaled separately | Requires synchronizing two schemas and accepting eventual consistency |
| CQRS with Event Sourcing | Domains that need auditing, complete history, or the ability to rebuild state at any point in time | Immutable history, debugging business bugs with full context | Higher operational complexity: event versioning, snapshots, replays |
Going Deeper: Eventual Consistency and Snapshots
When an aggregate accumulates thousands of events, rebuilding it by replaying all of them from scratch on every read becomes slow. The usual solution is the snapshot: store the computed state every N events (every 100, for example) and when rebuilding, start from the most recent snapshot instead of from event zero.
sequenceDiagram
participant Client
participant CommandHandler
participant EventStore
participant Projector
participant ReadModel
Client->>CommandHandler: CrearPedido
CommandHandler->>EventStore: store PedidoCreado v1
EventStore-->>Projector: notifies PedidoCreado
Projector->>ReadModel: update view
Note over EventStore,Projector: the update is asynchronous
Client->>ReadModel: ObtenerPedidos
ReadModel-->>Client: list of orders
The sequence diagram shows the eventual consistency window. Between the moment EventStore saves the event and Projector finishes updating ReadModel, an immediate query might not see the new order yet.
flowchart LR
A["Event 1: PedidoCreado"] --> B["Event 2: PedidoConfirmado"]
B --> C["Event 3: ItemAgregado"]
C --> D["Snapshot at event 100"]
D --> E["Event 101: PedidoEnviado"]
E --> F["Current state rebuilt"]
Rebuilding the current state no longer requires replaying the hundred events before the snapshot, just the snapshot plus the handful of events after it. Frameworks like Axon and EventStoreDB automate this mechanism, but implementing it by hand is simple: store the serialized state every N events along with the version number.
The way commands are tested also changes. Instead of mocking the database, a typical Event Sourcing test follows the given-when-then pattern: given a set of prior events, when a command runs, then a new event with specific content is expected to be published. Queries, on the other hand, are tested by inserting rows directly into the read model and verifying that the handler returns what’s expected, without touching the write side at all.
💭 Key point: CQRS and Event Sourcing are two independent patterns that complement each other well, but adopting one doesn’t require adopting the other. Most production systems use CQRS without storing the complete event history.
📖 Summary on Telegram: View summary
Your next step: clone a small Node.js project, split a single route into a command handler and a query handler with their own data access functions, and measure with autocannon whether reads stop degrading under write load.
Frequently Asked Questions
Does CQRS always need Event Sourcing?
No. CQRS only requires separating the write model from the read model. Event Sourcing is an additional technique for storing the complete history of changes, and many systems use CQRS without it.
What problem does CQRS solve that an ORM doesn’t?
An ORM still uses the same schema for reading and writing. CQRS separates the schemas, so the read view can be denormalized and optimized for specific queries without affecting the validation rules on the write side.
Does CQRS overcomplicate a small project?
Yes, in most cases. If the project doesn’t have a real bottleneck between reads and writes, adding two models and a synchronization mechanism only adds code to maintain.
What happens if the read model becomes outdated?
There’s a window of eventual consistency between when the command is confirmed and when the projection updates. It’s handled by returning the command’s result directly to the client, without relying on an immediate query to the read model.
How are duplicate events handled in the projector?
By storing the event id as a unique key in the read table and using an insert that ignores conflicts, so processing the same event twice doesn’t duplicate its effect on the view.
Is CQRS the same as microservices?
No. CQRS is a responsibility-separation pattern within a service or domain. It can be applied inside a monolith or inside an individual microservice, without depending on a distributed architecture.
References
- Martin Fowler, CQRS: the reference explanation of the pattern, with its advantages and warnings.
- Microsoft Learn, CQRS pattern: architecture guide with diagrams and implementation considerations.
- EventStore on GitHub: open source database designed specifically for Event Sourcing.
- MediatR on GitHub: reference library for implementing commands and queries in .NET.
📱 Enjoy this content? Follow @programacion on Telegram for daily tech content in Spanish: quick summaries, fresh content every day.
Imagen destacada: Foto de Google DeepMind en Unsplash
0 Comments