What is CQRS pattern and how to implement it in NestJS?
CQRS (Command Query Responsibility Segregation) splits an application into two separate paths: commands that mutate state, and queries that only read. NestJS ships @nestjs/cqrs to wire this up without building the routing layer from scratch.
Theory
TL;DR
- Restaurant analogy: the kitchen (commands) changes what is on the plate, the dining area (queries) just fetches the menu. Both sides scale independently.
- Instead of one
OrdersServicewithcreate()andfindOne(), you get aCreateOrderHandlerwriting to Postgres and aGetOrderHandlerreading from Redis. CommandBus.execute()triggers writes,QueryBus.execute()triggers reads.- NestJS matches commands to handlers at runtime via
Reflect.metadataon the class reference. - Use it when reads outnumber writes 10x or more, or when each side needs a completely different data shape.
Quick example
import { CqrsModule, CommandBus, QueryBus } from '@nestjs/cqrs';
export class CreateOrderCommand {
constructor(public readonly userId: string, public readonly items: Item[]) {}
}
export class GetOrderQuery {
constructor(public readonly orderId: string) {}
}
@Controller('orders')
export class OrdersController {
constructor(private commandBus: CommandBus, private queryBus: QueryBus) {}
@Post()
create(@Body() dto: CreateOrderDto) {
// Write path: routed to CreateOrderHandler
return this.commandBus.execute(new CreateOrderCommand(dto.userId, dto.items));
}
@Get(':id')
find(@Param('id') id: string) {
// Read path: routed to GetOrderHandler
return this.queryBus.execute(new GetOrderQuery(id));
}
}The controller only knows about buses, not handlers. The bus finds the right handler by matching the class passed to @CommandHandler() or @QueryHandler() at registration time.
Key difference from traditional CRUD
Standard NestJS code puts create() and findOne() in the same service, sharing one repository and one entity shape. CQRS forces a split: the command side validates business rules and writes to a normalized relational table, the query side reads from a denormalized cache or a dedicated read DB. That separation means you can spin up 100 query pods without touching the write path at all.
How @nestjs/cqrs works internally
CqrsModule registers CommandBus and QueryBus as singletons in the NestJS DI container. When you call commandBus.execute(new CreateOrderCommand(...)), the bus reads the constructor name via Reflect.metadata, scans providers for the handler decorated with @CommandHandler(CreateOrderCommand), and calls its execute() method. By default everything runs in-memory and in-process. For microservices, you replace the default transport with a Redis or RabbitMQ transporter so commands cross service boundaries.
When to use CQRS
- Read traffic is 10x or more than writes. Classic example: e-commerce product listings vs. order creation.
- Each side needs a different data shape. The write side enforces business invariants, the read side returns flat, denormalized views for fast UI rendering.
- You are building event-driven architecture where commands emit domain events and queries rebuild state from projections.
- You need independent scaling: one write pod handling transactions, many query pods serving cached responses.
Skip it for prototypes, internal tools, and any app without serious load. The handler/bus/event boilerplate triples your file count with zero payoff on small traffic.
Comparison table
| Aspect | Monolith CRUD | CQRS |
|---|---|---|
| Models | One entity for reads and writes | Separate command DTOs and query views |
| Database | One relational DB | Writes to Postgres, reads from Redis or Mongo |
| Scaling | Read replicas only | 1 write pod, 100 query pods independently |
| Complexity | Low | High: handlers, buses, eventual consistency |
| When to use | Prototypes, low-traffic apps | Systems with heavy read/write imbalance |
Common mistakes
Reusing one DTO for commands and queries:
// Wrong: one class serving both sides
class OrderDto {
userId: string;
items: Item[];
total: number; // queries display this, but the command doesn't compute it yet
}
// Right: separate shapes per side
class CreateOrderCommand { userId: string; items: Item[]; }
class OrderSummaryView { id: string; total: number; status: string; }Commands need input validation fields. Queries need display fields. One shared class couples the two sides and eliminates the separation the pattern exists for.
Forgetting to publish events after a command:
// Wrong: saves to DB but never notifies the read side
async execute(command: CreateOrderCommand) {
await this.repo.save(order);
// missing: this.eventBus.publish(new OrderCreatedEvent(order.id))
}Without events, query handlers reading from a Redis projection never know a write happened. I have seen teams spend hours on "phantom data" that turned out to be a missing eventBus.publish() call.
Using the in-memory bus in a distributed system:
// Wrong for microservices: commands stay in-process
@Module({ imports: [CqrsModule] })
// Right: configure a distributed transporter
// Plug in Redis or RabbitMQ transporter for cross-service dispatchThe default CqrsModule does not cross process boundaries. Commands sent in one service will never reach handlers in another service.
Skipping validation inside command handlers:
// Wrong: invalid input goes straight to the DB
async execute(command: CreateOrderCommand) {
await this.repo.save(command);
}
// Right: validate before touching the DB
async execute(command: CreateOrderCommand) {
if (!command.userId) throw new BadRequestException('userId required');
const order = new Order(command.userId, command.items);
await this.repo.save(order);
}Real-world usage
- MedusaJS: commands write to a Postgres orders table, queries read from an Elasticsearch products index.
- EventStoreDB + NestJS: commands append events, queries build read models from projections.
- Uber-like trip systems: trip creation as a command to Kafka, status queries from Cassandra projections.
- Admin panels with complex workflows: separate sagas handle multi-step operations without blocking read handlers.
Follow-up questions
Q: What is the difference between CQS and CQRS?
A: CQS (Command Query Separation) is a method-level rule: a function either changes state or returns data, not both at once. CQRS is architectural: separate models, handlers, and potentially separate databases for each side.
Q: How do you handle eventual consistency between the command and query sides?
A: Command handlers publish domain events after writing. Event handlers subscribe via @EventsHandler() and update read models in Redis or Mongo. The gap between write and projection update is usually milliseconds, but you need to design your UI to handle it explicitly.
Q: When does CQRS hurt more than help?
A: In apps with under 10k requests per day or teams new to domain-driven design. The boilerplate multiplies without any scaling payoff. Start with plain services and migrate only when read and write patterns clearly diverge.
Q: How do you query across bounded contexts?
A: Don't join across contexts. Use domain events to project data into a denormalized query store that belongs to the context making the request. Direct joins couple contexts and undo the architecture.
Q (senior): In a sharded system, how do you route commands to the right shard?
A: Put a shard key like tenantId or userId in the command metadata. The bus transporter uses consistent hashing to route. Kafka partitions work well here. Watch for hot shards if key distribution is uneven.
Examples
Basic setup: command handler and query handler
// npm install @nestjs/cqrs
// Command: carries the intent to write
export class CreateOrderCommand {
constructor(
public readonly userId: string,
public readonly items: { productId: string; quantity: number }[],
) {}
}
// Command handler: validates, writes to DB, fires event
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
constructor(
private readonly repo: OrderRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: CreateOrderCommand) {
if (!command.userId) throw new BadRequestException('userId required');
const order = await this.repo.save({
userId: command.userId,
items: command.items,
status: 'pending',
});
this.eventBus.publish(new OrderCreatedEvent(order.id, order.userId));
return order;
}
}
// Query: carries the intent to read
export class GetOrderByIdQuery {
constructor(public readonly orderId: string) {}
}
// Query handler: reads from the read repository (Redis, read replica, etc.)
@QueryHandler(GetOrderByIdQuery)
export class GetOrderByIdHandler implements IQueryHandler<GetOrderByIdQuery> {
constructor(private readonly readRepo: OrderReadRepository) {}
async execute(query: GetOrderByIdQuery) {
return this.readRepo.findById(query.orderId);
}
}The command handler writes to the main DB and fires an event. The query handler reads from a separate repository. They share nothing except the event that bridges them.
Intermediate: event handler updating the read model
export class OrderCreatedEvent {
constructor(
public readonly orderId: string,
public readonly userId: string,
) {}
}
// Event handler: keeps Redis projection in sync after every write
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedHandler implements IEventHandler<OrderCreatedEvent> {
constructor(
private readonly redisService: RedisService,
private readonly repo: OrderReadRepository,
) {}
async handle(event: OrderCreatedEvent) {
const order = await this.repo.findById(event.orderId);
const key = `user:${event.userId}:orders`;
const existing = JSON.parse(await this.redisService.get(key) || '[]');
await this.redisService.set(
key,
JSON.stringify([...existing, { id: order.id, status: order.status }]),
);
}
}
// Query handler reads from Redis instead of Postgres
@QueryHandler(GetUserOrdersQuery)
export class GetUserOrdersHandler implements IQueryHandler<GetUserOrdersQuery> {
constructor(private readonly redisService: RedisService) {}
async execute(query: GetUserOrdersQuery) {
const data = await this.redisService.get(`user:${query.userId}:orders`);
return data ? JSON.parse(data) : [];
}
}The event is the bridge. Without it, GetUserOrdersQuery returns stale data even after a successful write.
Advanced: wiring everything in the module
@Controller('orders')
export class OrdersController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async create(@Body() dto: CreateOrderDto, @CurrentUser() user: User) {
return this.commandBus.execute(new CreateOrderCommand(user.id, dto.items));
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.queryBus.execute(new GetOrderByIdQuery(id));
}
@Get('user/me')
async myOrders(@CurrentUser() user: User) {
return this.queryBus.execute(new GetUserOrdersQuery(user.id));
}
}
// Every handler must be in providers or the bus throws at runtime
const CommandHandlers = [CreateOrderHandler, CancelOrderHandler];
const QueryHandlers = [GetOrderByIdHandler, GetUserOrdersHandler];
const EventHandlers = [OrderCreatedHandler, OrderCancelledHandler];
@Module({
imports: [CqrsModule],
controllers: [OrdersController],
providers: [
...CommandHandlers,
...QueryHandlers,
...EventHandlers,
OrderRepository,
OrderReadRepository,
],
})
export class OrdersModule {}If a handler is missing from providers, the bus throws "no handler found for [ClassName]" at the first call. That is the most common runtime error when setting up @nestjs/cqrs for the first time.
Short Answer
Interview readyA concise answer to help you respond confidently on this topic during an interview.