CQRS is not an on/off switch. It's a scale: you take exactly as much as you need right now and add the next tier as you grow. Starting straight away with an event-driven architecture on a young service is expensive and pointless.
Let's break down the four steps: from an ordinary service to a full-fledged read-model with Kafka.
Tier 1: an ordinary service without CQRS
Most services start here. One @Injectable() class, methods for reading and writing in one place, TypeORM working directly.
@Injectable()
export class OrderService {
constructor(private readonly dataSource: DataSource) {}
async createOrder(dto: CreateOrderDto): Promise<string> {
const order = Order.create(dto.customerId, dto.items);
await this.dataSource.getRepository(OrderEntity).save(order.toEntity());
return order.id;
}
async getOrder(id: string): Promise<OrderDto> {
const row = await this.dataSource.getRepository(OrderEntity).findOneBy({ id });
if (!row) throw new OrderNotFoundError(id);
return toOrderDto(row);
}
}
When this is fine: simple CRUD services, internal utilities, proxies. There's no point complicating things while there's no real domain.
Tier 2: the Command and Query markers
When real business logic appears, the service starts to sprawl. Creating an order requires validation, discount calculation, sending events. Reading wants to assemble data from several tables. All of this in a single class turns into a mess.
The solution is to separate commands (writes) and queries (reads) explicitly, via the Command<R> and Query<R> markers.
// core/order/port/in/create-order.command.ts
export class CreateOrderCommand implements Command<OrderId> {
constructor(
readonly customerId: CustomerId,
readonly items: ReadonlyArray<OrderItemInput>,
) {}
}
// core/order/port/in/get-order.query.ts
export class GetOrderQuery implements Query<OrderSummary> {
constructor(readonly orderId: OrderId) {}
}
Each use-case gets its own handler:
// application/order/create-order.handler.ts
@Injectable()
export class CreateOrderHandler implements Handler<CreateOrderCommand, OrderId> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
@Inject(TX_RUNNER) private readonly tx: TransactionRunner,
) {}
async execute(cmd: CreateOrderCommand): Promise<OrderId> {
return this.tx.run(async () => {
const order = Order.create(cmd.customerId, cmd.items);
await this.orders.save(order);
return order.id;
});
}
}
// application/order/get-order.handler.ts
@Injectable()
export class GetOrderHandler implements Handler<GetOrderQuery, OrderSummary> {
constructor(
@Inject(ORDER_REPOSITORY) private readonly orders: OrderRepository,
) {}
async execute(query: GetOrderQuery): Promise<OrderSummary> {
const order = await this.orders.byId(query.orderId);
if (!order) throw new OrderNotFoundError(query.orderId);
return toOrderSummary(order);
}
}
Two important points at this tier:
- The query-handler works without a transaction. Reading doesn't need
tx.run()— this is deliberate. A transaction here would only add overhead without benefit. - A command returns the minimum.
CreateOrderHandlerreturnsOrderId, not the full object. If the UI needs data after creation — the controller callsGetOrderHandlerseparately.
Read and write still use a single OrderRepository. That's fine at this tier.
Tier 3 split: a separate repository for reading
Over time, read queries start to differ from the write model. The client portal wants to see customer_name right in the order row, without joins. Pagination with filters is needed. The TypeORM mapping of the aggregate is poorly suited to such queries.
The solution is to set up a separate OrderViewRepository interface specifically for reading.
// core/order/port/out/order.repository.ts — for writing
export interface OrderRepository {
byId(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}
// core/order/port/out/order-view.repository.ts — for reading
export interface OrderViewRepository {
summary(orderId: OrderId): Promise<OrderSummary | null>;
search(customerId: CustomerId, status: OrderStatus, page: PageRequest): Promise<Page<OrderSummary>>;
}
The OrderViewRepository implementation uses plain SQL instead of TypeORM methods:
@Injectable()
export class TypeOrmOrderViewRepository implements OrderViewRepository {
constructor(private readonly dataSource: DataSource) {}
async summary(orderId: OrderId): Promise<OrderSummary | null> {
const rows = await this.dataSource.query(
`SELECT o.id, o.status, o.customer_name, o.total_amount, o.created_at
FROM orders o
WHERE o.id = $1`,
[orderId],
);
return rows[0] ? toOrderSummary(rows[0]) : null;
}
async search(customerId: CustomerId, status: OrderStatus, page: PageRequest): Promise<Page<OrderSummary>> {
const rows = await this.dataSource.query(
`SELECT o.id, o.status, o.customer_name, o.total_amount, o.created_at
FROM orders o
WHERE o.customer_id = $1
AND ($2::text IS NULL OR o.status = $2)
ORDER BY o.created_at DESC
LIMIT $3 OFFSET $4`,
[customerId, status ?? null, page.size, page.offset()],
);
return toPage(rows, page);
}
}
The query-handler now works only with OrderViewRepository — it doesn't see the OrderRepository for writing:
@Injectable()
export class GetOrderHandler implements Handler<GetOrderQuery, OrderSummary> {
constructor(
@Inject(ORDER_VIEW_REPOSITORY) private readonly view: OrderViewRepository,
) {}
async execute(query: GetOrderQuery): Promise<OrderSummary> {
const summary = await this.view.summary(query.orderId);
if (!summary) throw new OrderNotFoundError(query.orderId);
return summary;
}
}
Physically everything is still in a single PostgreSQL — just different queries. The separation is at the code level so far, not the infrastructure.
Tier 3 event-driven: separate storage for the read-model
When read load starts to interfere with writes, or the read projection is fundamentally different (full-text search, analytical summaries), the read-model moves to separate storage.
Data gets there through events:
Write: Read:
PostgreSQL order_summary (PG table / Redis / ES)
├── orders ├── customer_name (denormalized)
└── outbox_events └── indexes for the needed queries
↓
outbox-relay (SKIP LOCKED)
↓
Kafka (order.events)
↓
consumer
↓
UPSERT order_summary
What appears additionally:
- The
outbox_eventstable — the event is written in the same transaction as the aggregate. This guarantees the event won't be lost on failure. - The outbox-relay — a NestJS task that reads unprocessed events via
SELECT ... FOR UPDATE SKIP LOCKEDand publishes them to Kafka. - The consumer — subscribes to the topic and updates
order_summaryvia UPSERT. - Protection against reprocessing — a
processed_eventtable or a version check in the UPSERT.
@Injectable()
export class OrderEventConsumer {
constructor(private readonly dataSource: DataSource) {}
@EventPattern('order.events')
async handle(payload: OrderEventPayload): Promise<void> {
await this.dataSource.transaction(async (em) => {
const already = await em.query(
`SELECT 1 FROM processed_event WHERE event_id = $1`, [payload.eventId],
);
if (already.length) return;
await em.query(
`INSERT INTO order_summary (id, status, customer_name, total_amount, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (id) DO UPDATE
SET status = EXCLUDED.status,
total_amount = EXCLUDED.total_amount,
updated_at = EXCLUDED.updated_at`,
[payload.orderId, payload.status, payload.customerName, payload.totalAmount],
);
await em.query(
`INSERT INTO processed_event (event_id, processed_at) VALUES ($1, NOW())`,
[payload.eventId],
);
});
}
}
You pay for this with read lag: data appears in the read-model after 100ms–1s under normal conditions, longer during failures. This is acceptable for most UIs, but it has to be accounted for.
Below this threshold a read replica plus a cache handles the job more cheaply.
How a service grows through the tiers
A typical path:
- Started as a small utility service. A flat
ProductService, TypeORM Entity exposed outward. Tier 1. - A real domain appeared: introduced
Command<R>/Query<R>,Handler,TransactionRunner. Tier 2. - The read side got more complex: the client portal wants projections with denormalized fields, without joins. Set up
ProductViewRepositorywith direct SQL. Tier 3 split. - Read load started hitting the write side during peaks — moved to a Redis projection with outbox and Kafka. Tier 3 event-driven.
Every transition is justified by real pain, not by the desire to use a "modern architecture".
Common mistakes
Markers without meaning. Adding Command<R> / Query<R> at Tier 1 without introducing a TransactionRunner or a separation of read and write paths is a box-ticking gesture. Either move fully to Tier 2 or remove the markers.
A single repository under event-driven infrastructure. If the read-model is already in separate storage but the query-handler still injects ORDER_REPOSITORY — the separation loses its meaning. You need a separate ORDER_VIEW_REPOSITORY.
Jumping over tiers. Starting with event-driven, skipping Tier 2 and Tier 3-split — excess complexity without practice. Each tier builds on the previous one.
A query-handler with a transaction. Wrapping a read in tx.run() at Tier 2 is unnecessary overhead. The Query<R> marker means exactly "without a transaction".
In short
- CQRS is a maturity scale: you take exactly as much as you need right now.
- Tier 1 — an ordinary
@Injectable()service; markers aren't needed. - Tier 2 —
Command<R>/Query<R>are mandatory; the query-handler has no transaction; a singleOrderRepository. - Tier 3 split — a separate
OrderViewRepositorywith direct SQL; physically one DB. - Tier 3 event-driven — the read-model in separate storage, synchronization via outbox + Kafka; you accept data lag.
- Transitions strictly go bottom-up, each justified by real load or pain.
What to read next
- When CQRS is justified in NestJS — the thresholds for moving between tiers.
- Command side in NestJS — how the write-handler is built at Tier 2 and 3.
- Query side in NestJS — the query-handler with a
ViewRepositoryand direct SQL.