Skip to main content

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 OrdersService with create() and findOne(), you get a CreateOrderHandler writing to Postgres and a GetOrderHandler reading from Redis.
  • CommandBus.execute() triggers writes, QueryBus.execute() triggers reads.
  • NestJS matches commands to handlers at runtime via Reflect.metadata on the class reference.
  • Use it when reads outnumber writes 10x or more, or when each side needs a completely different data shape.

Quick example

typescript
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

AspectMonolith CRUDCQRS
ModelsOne entity for reads and writesSeparate command DTOs and query views
DatabaseOne relational DBWrites to Postgres, reads from Redis or Mongo
ScalingRead replicas only1 write pod, 100 query pods independently
ComplexityLowHigh: handlers, buses, eventual consistency
When to usePrototypes, low-traffic appsSystems with heavy read/write imbalance

Common mistakes

Reusing one DTO for commands and queries:

typescript
// 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:

typescript
// 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:

typescript
// Wrong for microservices: commands stay in-process @Module({ imports: [CqrsModule] }) // Right: configure a distributed transporter // Plug in Redis or RabbitMQ transporter for cross-service dispatch

The default CqrsModule does not cross process boundaries. Commands sent in one service will never reach handlers in another service.

Skipping validation inside command handlers:

typescript
// 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

typescript
// 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

typescript
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

typescript
@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 ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?