If you've been building NestJS applications with Clean Architecture, you've probably hit a point where your services are doing too much. They're handling commands, running queries, validating business rules, and mapping data all at the same time. That's where CQRS comes in.
CQRS stands for Command Query Responsibility Segregation. The idea is straightforward: separate the code that writes data (commands) from the code that reads data (queries). Instead of one service doing everything, you get dedicated handlers for each operation. Your write side enforces business rules through aggregates, and your read side returns optimized data through lightweight query handlers.
If you've been following along with my previous articles on Domain Events in NestJS, Domain-Driven Design, and Clean Architecture Application Structure In NestJS Framework, CQRS is a natural next step. It builds on all of those concepts and gives you a clean, scalable way to organize your application logic.
Let's build a real-world Order system to see how it all fits together.
When to Use CQRS (and When NOT To)
Before we jump in, let's be honest about when CQRS actually makes sense.
Use CQRS when:
- Your read and write models have different shapes (the data you write is not the same as the data you read)
- Your domain has complex business rules that need to be enforced on the write side
- You need to scale reads and writes independently
- Your application has significantly more reads than writes (or vice versa)
- You're already using Domain-Driven Design and want a clean separation of concerns
Don't use CQRS when:
- You're building a simple CRUD application with no real business logic
- Your read and write models are essentially the same
- You have a small team and the added complexity isn't worth it
- You're early in the project and don't yet understand the domain well enough
CQRS adds indirection. Every operation now goes through a command or query object, a bus, and a handler. For simple apps, that's unnecessary ceremony. For complex domains, it's a lifesaver.
How CQRS Maps to Clean Architecture Layers
This is where it gets interesting. CQRS maps beautifully to Clean Architecture layers:
- Domain Layer - Aggregates, entities, value objects, and domain events. The Order aggregate lives here and enforces all business rules
- Application Layer - Commands, queries, and their handlers. This is where
PlaceOrderCommandandGetOrderHistoryQueryare defined along with their handlers - Infrastructure Layer - Repositories, database adapters, and the actual NestJS CQRS module wiring. Handlers use repository ports defined in the application layer
- Interface Adapters Layer - Controllers that dispatch commands and queries through the bus
Here's how the folder structure looks:
src/
modules/
order/
domain/
aggregates/
order.aggregate.ts
value-objects/
order-item.vo.ts
events/
order-placed.event.ts
application/
commands/
place-order/
place-order.command.ts
place-order.handler.ts
queries/
get-order-history/
get-order-history.query.ts
get-order-history.handler.ts
ports/
order.repository.interface.ts
dtos/
order-response.dto.ts
infrastructure/
repositories/
order.typeorm.repository.ts
interface-adapters/
controllers/
order.controller.ts
Commands and queries live in the Application Layer because they represent use cases. The handlers orchestrate the flow but delegate business rules to the domain. The domain layer has no idea that CQRS even exists.
Setting Up @nestjs/cqrs
First, install the package:
npm install @nestjs/cqrs
Then register the CqrsModule in your order module:
// src/modules/order/order.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { PlaceOrderHandler } from './application/commands/place-order/place-order.handler';
import { GetOrderHistoryHandler } from './application/queries/get-order-history/get-order-history.handler';
import { OrderController } from './interface-adapters/controllers/order.controller';
import { OrderRepository } from './infrastructure/repositories/order.typeorm.repository';
import { ORDER_REPOSITORY } from './application/ports/order.repository.interface';
@Module({
imports: [CqrsModule],
controllers: [OrderController],
providers: [
PlaceOrderHandler,
GetOrderHistoryHandler,
{
provide: ORDER_REPOSITORY,
useClass: OrderRepository,
},
],
})
export class OrderModule {}
That's it for setup. The CqrsModule provides the CommandBus and QueryBus that we'll use to dispatch commands and queries.
Implementing the Write Side
The write side is where all the interesting stuff happens. We need three things: a command, a handler, and an aggregate.
The Command
A command is just a plain class that carries data. No logic, no methods, just data.
// src/modules/order/application/commands/place-order/place-order.command.ts
export interface PlaceOrderItem {
readonly productId: string;
readonly productName: string;
readonly quantity: number;
readonly unitPrice: number;
}
export class PlaceOrderCommand {
public constructor(
public readonly customerId: string,
public readonly items: ReadonlyArray<PlaceOrderItem>,
public readonly shippingAddress: string,
) {}
}
The Order Aggregate
The aggregate is the heart of the write side. It enforces all business rules and records domain events. Notice how it doesn't know about databases, HTTP, or NestJS. It's pure domain logic.
// src/modules/order/domain/aggregates/order.aggregate.ts
import { AggregateRoot } from '@nestjs/cqrs';
import { OrderPlacedEvent } from '../events/order-placed.event';
import { OrderItem } from '../value-objects/order-item.vo';
export type OrderStatus = 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
export class Order extends AggregateRoot {
private _orderId: string;
private _customerId: string;
private _items: OrderItem[];
private _shippingAddress: string;
private _totalAmount: number;
private _status: OrderStatus;
private _createdAt: Date;
public get orderId(): string {
return this._orderId;
}
public get customerId(): string {
return this._customerId;
}
public get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
public get totalAmount(): number {
return this._totalAmount;
}
public get status(): OrderStatus {
return this._status;
}
public get shippingAddress(): string {
return this._shippingAddress;
}
public get createdAt(): Date {
return this._createdAt;
}
public static place(
orderId: string,
customerId: string,
items: OrderItem[],
shippingAddress: string,
): Order {
const order = new Order();
order.validateItems(items);
order._orderId = orderId;
order._customerId = customerId;
order._items = items;
order._shippingAddress = shippingAddress;
order._totalAmount = order.calculateTotal(items);
order._status = 'pending';
order._createdAt = new Date();
order.validateTotalAmount();
order.apply(
new OrderPlacedEvent(
order._orderId,
order._customerId,
order._totalAmount,
order._items.length,
),
);
return order;
}
private validateItems(items: OrderItem[]): void {
if (!items || items.length === 0) {
throw new Error('Cannot place an order with empty items');
}
}
private validateTotalAmount(): void {
if (this._totalAmount <= 0) {
throw new Error('Order total must be positive');
}
}
private calculateTotal(items: OrderItem[]): number {
return items.reduce(
(sum, item) => sum + item.unitPrice * item.quantity,
0,
);
}
}
The Value Object
// src/modules/order/domain/value-objects/order-item.vo.ts
export class OrderItem {
public constructor(
public readonly productId: string,
public readonly productName: string,
public readonly quantity: number,
public readonly unitPrice: number,
) {
if (quantity <= 0) {
throw new Error('Order item quantity must be greater than zero');
}
if (unitPrice < 0) {
throw new Error('Order item unit price cannot be negative');
}
}
public get subtotal(): number {
return this.quantity * this.unitPrice;
}
}
The Domain Event
// src/modules/order/domain/events/order-placed.event.ts
export class OrderPlacedEvent {
public constructor(
public readonly orderId: string,
public readonly customerId: string,
public readonly totalAmount: number,
public readonly itemCount: number,
) {}
}
The Command Handler
The handler is the use case orchestrator. It receives the command, builds the aggregate, and persists it. If you've read my Domain Events in NestJS article, this pattern should look familiar.
// src/modules/order/application/commands/place-order/place-order.handler.ts
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { PlaceOrderCommand } from './place-order.command';
import { Order } from '../../../domain/aggregates/order.aggregate';
import { OrderItem } from '../../../domain/value-objects/order-item.vo';
import {
IOrderRepository,
ORDER_REPOSITORY,
} from '../../ports/order.repository.interface';
import { randomUUID } from 'crypto';
@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
public constructor(
@Inject(ORDER_REPOSITORY)
private readonly orderRepository: IOrderRepository,
private readonly eventPublisher: EventPublisher,
) {}
public async execute(command: PlaceOrderCommand): Promise<string> {
const orderItems = command.items.map(
(item) =>
new OrderItem(
item.productId,
item.productName,
item.quantity,
item.unitPrice,
),
);
const order = this.eventPublisher.mergeObjectContext(
Order.place(
randomUUID(),
command.customerId,
orderItems,
command.shippingAddress,
),
);
await this.orderRepository.save(order);
order.commit();
return order.orderId;
}
}
The EventPublisher from @nestjs/cqrs merges the event context into our aggregate so that when we call order.commit(), all the domain events that were applied during Order.place() get published to the event bus.
The Repository Port
// src/modules/order/application/ports/order.repository.interface.ts
import { Order } from '../../domain/aggregates/order.aggregate';
import { OrderResponseDto } from '../dtos/order-response.dto';
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');
export interface IOrderRepository {
save(order: Order): Promise<void>;
findByCustomerId(customerId: string): Promise<OrderResponseDto[]>;
findById(orderId: string): Promise<Order | null>;
}
Implementing the Read Side
The read side is intentionally simple. No aggregates, no business rules, just fetch and return. This is where you can optimize for read performance without worrying about domain invariants.
The Query
// src/modules/order/application/queries/get-order-history/get-order-history.query.ts
export class GetOrderHistoryQuery {
public constructor(
public readonly customerId: string,
public readonly limit: number = 20,
public readonly offset: number = 0,
) {}
}
The Read Model DTO
// src/modules/order/application/dtos/order-response.dto.ts
export interface OrderItemResponseDto {
readonly productId: string;
readonly productName: string;
readonly quantity: number;
readonly unitPrice: number;
readonly subtotal: number;
}
export interface OrderResponseDto {
readonly orderId: string;
readonly customerId: string;
readonly items: ReadonlyArray<OrderItemResponseDto>;
readonly totalAmount: number;
readonly status: string;
readonly shippingAddress: string;
readonly createdAt: Date;
}
The Query Handler
// src/modules/order/application/queries/get-order-history/get-order-history.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { GetOrderHistoryQuery } from './get-order-history.query';
import { OrderResponseDto } from '../../dtos/order-response.dto';
import {
IOrderRepository,
ORDER_REPOSITORY,
} from '../../ports/order.repository.interface';
@QueryHandler(GetOrderHistoryQuery)
export class GetOrderHistoryHandler
implements IQueryHandler<GetOrderHistoryQuery>
{
public constructor(
@Inject(ORDER_REPOSITORY)
private readonly orderRepository: IOrderRepository,
) {}
public async execute(
query: GetOrderHistoryQuery,
): Promise<OrderResponseDto[]> {
return this.orderRepository.findByCustomerId(query.customerId);
}
}
Notice how the query handler goes straight to the repository and returns DTOs. There's no aggregate involved. In a more advanced setup, you could even point reads at a completely different database optimized for queries (like a denormalized read store), but for most applications, querying the same database through a read-optimized path is perfectly fine.
Connecting Commands and Queries to Controllers
The controller is thin. It just translates HTTP requests into commands or queries and dispatches them through the bus.
// src/modules/order/interface-adapters/controllers/order.controller.ts
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { PlaceOrderCommand, PlaceOrderItem } from '../../application/commands/place-order/place-order.command';
import { GetOrderHistoryQuery } from '../../application/queries/get-order-history/get-order-history.query';
import { OrderResponseDto } from '../../application/dtos/order-response.dto';
interface PlaceOrderRequestBody {
readonly customerId: string;
readonly items: PlaceOrderItem[];
readonly shippingAddress: string;
}
interface PlaceOrderResponseBody {
readonly orderId: string;
}
@Controller('orders')
export class OrderController {
public constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
public async placeOrder(
@Body() body: PlaceOrderRequestBody,
): Promise<PlaceOrderResponseBody> {
const orderId = await this.commandBus.execute<PlaceOrderCommand, string>(
new PlaceOrderCommand(
body.customerId,
body.items,
body.shippingAddress,
),
);
return { orderId };
}
@Get('history/:customerId')
public async getOrderHistory(
@Param('customerId') customerId: string,
@Query('limit') limit?: string,
@Query('offset') offset?: string,
): Promise<OrderResponseDto[]> {
return this.queryBus.execute<GetOrderHistoryQuery, OrderResponseDto[]>(
new GetOrderHistoryQuery(
customerId,
limit ? parseInt(limit, 10) : 20,
offset ? parseInt(offset, 10) : 0,
),
);
}
}
The controller doesn't know about repositories, aggregates, or business rules. It only knows about the command bus and query bus. This is exactly the kind of separation we want.
Using Sagas for Cross-Aggregate Workflows
Sometimes placing an order needs to trigger actions in other aggregates, like reserving inventory or sending a confirmation email. That's where sagas come in. A saga listens for domain events and dispatches new commands in response.
// src/modules/order/application/sagas/order.saga.ts
import { Injectable } from '@nestjs/common';
import { ICommand, ofType, Saga } from '@nestjs/cqrs';
import { Observable, map } from 'rxjs';
import { OrderPlacedEvent } from '../../domain/events/order-placed.event';
import { ReserveInventoryCommand } from '../../../inventory/application/commands/reserve-inventory/reserve-inventory.command';
@Injectable()
export class OrderSaga {
@Saga()
public orderPlaced(events$: Observable<OrderPlacedEvent>): Observable<ICommand> {
return events$.pipe(
ofType(OrderPlacedEvent),
map(
(event) =>
new ReserveInventoryCommand(event.orderId, event.itemCount),
),
);
}
}
Register the saga in your module's providers, and it will automatically react to OrderPlacedEvent being published. This is the same pattern I covered in more detail in my Domain Events in NestJS article, but now it's wired through the CQRS event bus.
Keep sagas thin. They should only map events to commands, not contain business logic. If the logic gets complex, it belongs in a command handler.
Testing Command and Query Handlers
One of the biggest wins of CQRS is testability. Handlers are just classes with a single execute method. You inject mocked dependencies and assert the behavior.
Testing the Command Handler
// src/modules/order/application/commands/place-order/place-order.handler.spec.ts
import { PlaceOrderHandler } from './place-order.handler';
import { PlaceOrderCommand } from './place-order.command';
import { IOrderRepository } from '../../ports/order.repository.interface';
import { EventPublisher } from '@nestjs/cqrs';
import { Order } from '../../../domain/aggregates/order.aggregate';
describe('PlaceOrderHandler', () => {
let handler: PlaceOrderHandler;
let orderRepository: jest.Mocked<IOrderRepository>;
let eventPublisher: jest.Mocked<EventPublisher>;
beforeEach(() => {
orderRepository = {
save: jest.fn().mockResolvedValue(undefined),
findByCustomerId: jest.fn(),
findById: jest.fn(),
};
eventPublisher = {
mergeObjectContext: jest.fn().mockImplementation(
(aggregate: Order) => aggregate,
),
} as unknown as jest.Mocked<EventPublisher>;
handler = new PlaceOrderHandler(orderRepository, eventPublisher);
});
it('should place an order and return the order id', async () => {
const command = new PlaceOrderCommand(
'customer-123',
[
{
productId: 'product-1',
productName: 'Wireless Keyboard',
quantity: 2,
unitPrice: 49.99,
},
],
'123 Main Street',
);
const orderId = await handler.execute(command);
expect(orderId).toBeDefined();
expect(orderRepository.save).toHaveBeenCalledTimes(1);
expect(eventPublisher.mergeObjectContext).toHaveBeenCalledTimes(1);
});
it('should throw when items array is empty', async () => {
const command = new PlaceOrderCommand(
'customer-123',
[],
'123 Main Street',
);
await expect(handler.execute(command)).rejects.toThrow(
'Cannot place an order with empty items',
);
expect(orderRepository.save).not.toHaveBeenCalled();
});
});
Testing the Query Handler
// src/modules/order/application/queries/get-order-history/get-order-history.handler.spec.ts
import { GetOrderHistoryHandler } from './get-order-history.handler';
import { GetOrderHistoryQuery } from './get-order-history.query';
import { IOrderRepository } from '../../ports/order.repository.interface';
import { OrderResponseDto } from '../../dtos/order-response.dto';
describe('GetOrderHistoryHandler', () => {
let handler: GetOrderHistoryHandler;
let orderRepository: jest.Mocked<IOrderRepository>;
beforeEach(() => {
orderRepository = {
save: jest.fn(),
findByCustomerId: jest.fn(),
findById: jest.fn(),
};
handler = new GetOrderHistoryHandler(orderRepository);
});
it('should return order history for a customer', async () => {
const expectedOrders: OrderResponseDto[] = [
{
orderId: 'order-1',
customerId: 'customer-123',
items: [
{
productId: 'product-1',
productName: 'Wireless Keyboard',
quantity: 2,
unitPrice: 49.99,
subtotal: 99.98,
},
],
totalAmount: 99.98,
status: 'pending',
shippingAddress: '123 Main Street',
createdAt: new Date('2025-01-15'),
},
];
orderRepository.findByCustomerId.mockResolvedValue(expectedOrders);
const query = new GetOrderHistoryQuery('customer-123');
const result = await handler.execute(query);
expect(result).toEqual(expectedOrders);
expect(orderRepository.findByCustomerId).toHaveBeenCalledWith(
'customer-123',
);
});
it('should return empty array when customer has no orders', async () => {
orderRepository.findByCustomerId.mockResolvedValue([]);
const query = new GetOrderHistoryQuery('customer-456');
const result = await handler.execute(query);
expect(result).toEqual([]);
});
});
The write side tests verify that business rules are enforced and the repository is called correctly. The read side tests are simpler because there are no business rules to enforce, just data retrieval.
Conclusion
CQRS isn't about making things complicated for the sake of it. It's about giving your write side and read side the freedom to evolve independently. Your commands go through aggregates that enforce business rules, your queries return exactly the data the client needs, and sagas handle cross-aggregate workflows without coupling everything together.
When paired with Clean Architecture, each piece has a clear home. Commands and queries live in the application layer. Aggregates live in the domain layer. Handlers orchestrate but don't contain business logic. Controllers are thin dispatchers.
Start simple. You don't need separate databases for reads and writes on day one. Just separating the code paths gives you most of the benefits. You can always optimize the read side later when you actually need it.
