CQRS stands for Command Query Responsibility Segregation — the separation of the responsibilities of commands and queries. In its simplest form it's two kinds of handlers with different rules. In its complex form it's two physically distinct storages synchronized through events.
Between these extremes lies a whole spectrum. This article will help you understand where your task sits on that spectrum.
Why you can't just "turn CQRS on" everywhere
CQRS is not a free feature you can add "just in case". Each tier of separation carries a cost:
- separate classes for reading and writing — more code;
- a separate table for reading — synchronization is needed;
- separate storage — two servers, eventual consistency, data rebuild scripts.
We apply CQRS where the benefit covers the cost, not because "that's the architecturally correct thing".
The good news: CQRS is not a binary choice. It's a bottom-up evolution — you start with the minimum and add complexity only when there's concrete pain.
Tier 1: Command and query markers — the start is always here
The cheapest kind of CQRS is simply to mark that there are commands (change state) and queries (read only). No additional infrastructure.
// core/order/port/confirm-order.command.ts
export class ConfirmOrder implements Command<OrderId> {
constructor(readonly orderId: OrderId) {}
}
// core/order/port/get-order-summary.query.ts
export class GetOrderSummary implements Query<OrderSummary> {
constructor(readonly orderId: OrderId) {}
}
The command handler works in a transaction and may write to the database. The query handler works without a transaction, reading only:
// adapters/in/http/order.controller.ts
@Post(':id/confirm')
async confirm(@Param('id') id: string): Promise<{ orderId: string }> {
const orderId = await this.bus.execute(new ConfirmOrder(OrderId.of(id)));
return { orderId: orderId.value };
}
@Get(':id/summary')
async summary(@Param('id') id: string): Promise<OrderSummary> {
return this.bus.execute(new GetOrderSummary(OrderId.of(id)));
}
What this gives you:
- TypeScript won't let you mix up the handlers — the types are different.
- It's immediately clear where transactions are needed and where they aren't.
- Metrics can be split by semantics:
command_totalandquery_totalseparately. - Reads and writes still hit the same table — no extra infrastructure.
This separation costs zero effort and always makes sense as soon as the application is a bit more complex than a "CRUD endpoint".
Tier 2: A separate table for reading — when queries get heavy
Imagine a user's "My orders" page. To show the list you need to gather data from six tables: order, order_item, product, customer, payment, shipment. Every page request means a heavy JOIN.
Or a dashboard with aggregates: GROUP BY customer_id, DATE(created_at) over millions of rows, and that on every page load.
In such cases a separate denormalized table specifically for reading helps:
CREATE TABLE order_summary (
order_id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
customer_name TEXT NOT NULL,
status TEXT NOT NULL,
item_count INTEGER NOT NULL,
total_amount NUMERIC(19,4) NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX ix_order_summary_customer ON order_summary(customer_id, created_at DESC);
Now for the orders page you have one simple SELECT by index instead of six JOINs. The table structure mirrors what the UI needs, not the normalized database schema.
A separate repository for reading with direct SQL queries:
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async byCustomer(
customerId: CustomerId,
page: number,
size: number,
): Promise<OrderSummary[]> {
const rows = await this.dataSource.query(
`SELECT order_id, customer_name, status, item_count, total_amount, created_at
FROM order_summary
WHERE customer_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3`,
[customerId.value, size, page * size],
);
return rows.map(toOrderSummary);
}
}
Both tables live in a single PostgreSQL — this isn't two storages, just denormalization within one database. Synchronizing order_summary when an order changes is an extra step in the command's transaction or via events inside the service.
It's worth moving to this tier when:
- a typical query does a JOIN across five or more tables;
- heavy aggregations noticeably load the database;
- the structure the UI needs differs substantially from the normalized schema.
Tier 3: Different storages — only for a measured problem
Sometimes PostgreSQL physically can't cope with the read load, or the task requires the capabilities of another storage. Then write and read diverge into different systems.
Typical cases:
- A read:write ratio ≥ 10:1. In e-commerce, one placed order corresponds to dozens of product-page views. At such a ratio it's reading that dictates the architecture.
- Full-text search. Searching descriptions with facets, ranking, and highlighting is a job for ElasticSearch with an inverted index, not for PostgreSQL.
- Horizontal read scaling. PostgreSQL holds 5k read/s, but Redis or ElasticSearch can hold 100k+. If read load exceeds the write database's capacity — we split reads off separately.
The scheme: write — PostgreSQL with transactions and locks. After each change an event is published (via Kafka or an internal outbox). A separate consumer updates the read storage.
@Injectable()
export class EsOrderViewRepository implements OrderViewRepository {
constructor(private readonly es: ElasticsearchService) {}
async search(query: string, customerId: CustomerId): Promise<OrderSummary[]> {
const result = await this.es.search({
index: 'orders',
body: {
query: {
bool: {
must: { multi_match: { query, fields: ['description', 'customer_name', 'status'] } },
filter: { term: { customer_id: customerId.value } },
},
},
},
});
return result.hits.hits.map((h) => toOrderSummary(h._source));
}
}
The cost of this tier is high: two storages, monitoring of both, eventual consistency (data in the read storage may lag), index-rebuild scripts for failures. It pays off only when the previous tiers can no longer cope.
A common mistake: full CQRS from day one
A new service, not yet launched in production. The team decides to use ElasticSearch for read projections "because there will be a lot of reads". Three months later: 200 requests a day, read load — 3 requests per second, and the team is sorting out desyncs and maintaining rebuild scripts with no real benefit whatsoever.
Command and query markers with a single PostgreSQL would have covered this task completely.
The rule is simple: start with markers, add complexity only for concrete, measured pain.
In short
- CQRS is a spectrum, not a binary choice. The evolution goes bottom-up.
- Tier 1 (markers) — separate
Command<R>andQuery<R>in the code. Free, always makes sense, a single database. - Tier 2 (a separate table) — a denormalized read table in the same database. Helps with heavy JOINs and aggregations.
- Tier 3 (different storages) — a write DB + ElasticSearch/Redis. Only at read:write ≥ 10:1 or for a specific task like full-text search.
- Launching a new service straight away with different storages without a measured problem is expensive and useless.
- The query handler never writes to the database and never runs in a transaction.
What to read next
- Commands in CQRS — how the write handler with a transaction is built.
- Queries in CQRS — the read handler without a transaction and a separate repository for reading.
- Read-model — how to store and update a denormalized projection.