Skip to content

CQRS in NestJS

Aman Kumar Singh7 min read
Part 31 of 40From the NestJS Production Guide series
CQRS in NestJS — article by Aman Kumar Singh

Last time in the NestJS Production Guide, I covered Pagination and Filtering, which is the kind of problem every list endpoint eventually runs into. CQRS sits a level up from that. It's a decision about how your whole write path relates to your whole read path, and it's one of the patterns people reach for far too early.

Command Query Responsibility Segregation means exactly what it says: the code that changes state and the code that reads state are separate objects, often on separate paths entirely. NestJS ships an official @nestjs/cqrs package that makes this easy to bolt onto a Nest app, which is both the appeal and the danger. Easy to add doesn't mean cheap to maintain.

I want to walk through what CQRS actually buys you, how it looks in a real NestJS module, and where teams get hurt applying it to problems that didn't need it.

What problem CQRS actually solves

In a typical NestJS service, a controller method calls a service method, the service method does some validation, writes to the database, maybe reads back the result, and returns it. Reads and writes flow through the same class, often the same method. For most CRUD operations, this is fine. It's simple and traceable enough that a new engineer can follow the whole path in one file.

The friction shows up when your read model and your write model genuinely diverge. A write to an Order needs strict validation, inventory checks, and a handful of invariants enforced before anything touches the database. A read of that same order, for a dashboard, might need to join across five tables, denormalize some fields, and be shaped completely differently for the UI that's consuming it. Forcing both operations through one service class means the service either grows a pile of read-shaping logic it shouldn't own, or the read queries get bolted onto write-side methods that were designed for something else.

CQRS separates these concerns into two object types: commands, which represent an intent to change state (CreateOrderCommand, CancelSubscriptionCommand), and queries, which represent an intent to read state (GetOrderByIdQuery, ListOrdersForOrgQuery). Each gets its own handler. Nothing forces the query side to reuse the write side's domain model, so it's free to be shaped purely around what the UI or API consumer needs.

That's the real value: independent evolution of the read and write paths. A well-indexed PostgreSQL table with a single service class can handle enormous read and write volume without any of this, so treat CQRS as a code-organization decision rather than a performance or scaling requirement. CQRS earns its keep once the shapes of your reads and writes have actually diverged enough that forcing them together creates awkward code.

Wiring it up with @nestjs/cqrs

Here's a command handling a subscription upgrade, structured the way @nestjs/cqrs expects.

npm install @nestjs/cqrs
// src/modules/subscriptions/commands/upgrade-subscription.command.ts
export class UpgradeSubscriptionCommand {
  constructor(
    public readonly subscriptionId: string,
    public readonly newPlanId: string,
    public readonly requestedBy: string,
  ) {}
}
// src/modules/subscriptions/commands/handlers/upgrade-subscription.handler.ts
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
import { NotFoundException } from '@nestjs/common';
import { UpgradeSubscriptionCommand } from '../upgrade-subscription.command';
import { SubscriptionsRepository } from '../../subscriptions.repository';
import { SubscriptionUpgradedEvent } from '../../events/subscription-upgraded.event';

@CommandHandler(UpgradeSubscriptionCommand)
export class UpgradeSubscriptionHandler
  implements ICommandHandler<UpgradeSubscriptionCommand>
{
  constructor(
    private readonly repository: SubscriptionsRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: UpgradeSubscriptionCommand): Promise<void> {
    const subscription = await this.repository.findById(command.subscriptionId);
    if (!subscription) {
      throw new NotFoundException('Subscription not found');
    }

    subscription.upgradeTo(command.newPlanId);
    await this.repository.save(subscription);

    this.eventBus.publish(
      new SubscriptionUpgradedEvent(subscription.id, command.newPlanId, command.requestedBy),
    );
  }
}

The controller stops calling a service directly and dispatches a command instead:

// src/modules/subscriptions/subscriptions.controller.ts
import { Body, Controller, Param, Post } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import { UpgradeSubscriptionCommand } from './commands/upgrade-subscription.command';
import { UpgradeSubscriptionDto } from './dto/upgrade-subscription.dto';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../../common/types/authenticated-user';

@Controller('subscriptions')
export class SubscriptionsController {
  constructor(private readonly commandBus: CommandBus) {}

  @Post(':id/upgrade')
  upgrade(
    @Param('id') id: string,
    @Body() dto: UpgradeSubscriptionDto,
    @CurrentUser() user: AuthenticatedUser,
  ) {
    return this.commandBus.execute(
      new UpgradeSubscriptionCommand(id, dto.planId, user.id),
    );
  }
}

The query side is a mirror image, but note it doesn't have to touch the same repository or even the same tables. This handler reads from a denormalized view built for exactly this dashboard:

// src/modules/subscriptions/queries/handlers/get-org-billing-summary.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetOrgBillingSummaryQuery } from '../get-org-billing-summary.query';
import { BillingSummaryReadRepository } from '../../billing-summary.read-repository';

@QueryHandler(GetOrgBillingSummaryQuery)
export class GetOrgBillingSummaryHandler
  implements IQueryHandler<GetOrgBillingSummaryQuery>
{
  constructor(private readonly readRepository: BillingSummaryReadRepository) {}

  execute(query: GetOrgBillingSummaryQuery) {
    return this.readRepository.findByOrgId(query.orgId);
  }
}

Register handlers in the module and NestJS's CqrsModule wires the dispatch for you:

// src/modules/subscriptions/subscriptions.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { SubscriptionsController } from './subscriptions.controller';
import { UpgradeSubscriptionHandler } from './commands/handlers/upgrade-subscription.handler';
import { GetOrgBillingSummaryHandler } from './queries/handlers/get-org-billing-summary.handler';
import { SubscriptionUpgradedHandler } from './events/handlers/subscription-upgraded.handler';

@Module({
  imports: [CqrsModule],
  controllers: [SubscriptionsController],
  providers: [
    UpgradeSubscriptionHandler,
    GetOrgBillingSummaryHandler,
    SubscriptionUpgradedHandler,
  ],
})
export class SubscriptionsModule {}

The event handler is where side effects live, deliberately kept out of the command handler that triggered them:

// src/modules/subscriptions/events/handlers/subscription-upgraded.handler.ts
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { Logger } from '@nestjs/common';
import { SubscriptionUpgradedEvent } from '../subscription-upgraded.event';
import { BillingEmailService } from '../../billing-email.service';

@EventsHandler(SubscriptionUpgradedEvent)
export class SubscriptionUpgradedHandler
  implements IEventHandler<SubscriptionUpgradedEvent>
{
  private readonly logger = new Logger(SubscriptionUpgradedHandler.name);

  constructor(private readonly billingEmailService: BillingEmailService) {}

  async handle(event: SubscriptionUpgradedEvent): Promise<void> {
    this.logger.log(`Subscription ${event.subscriptionId} upgraded to ${event.newPlanId}`);
    await this.billingEmailService.sendUpgradeConfirmation(event.subscriptionId);
  }
}

That last piece is worth pausing on. Publishing SubscriptionUpgradedEvent after the command commits is what actually lets you decouple side effects, sending a confirmation email, updating analytics, notifying another module, from the core write. We'll go deeper on structuring these event flows in the next article.

The command and query split isn't the same as separate databases

A common misconception is that CQRS requires two physically separate data stores, one optimized for writes and one for reads, synced by some replication or projection mechanism. That's an extreme, fully-featured version of the pattern, and it's the version most people picture when they hear the term. It also brings real costs: eventual consistency between the two stores, projection code that has to stay correct, and an operational burden of running and monitoring a second data path.

Most teams that benefit from CQRS never need that. A perfectly reasonable middle ground is one PostgreSQL database, with command handlers writing through your normal domain tables and query handlers reading from either those same tables or from database views built specifically for read shapes. No second database, no replication lag, no projection worker. You get the code-level separation, the class boundary between "this touches write logic" and "this is read-only," without the operational cost of a second store.

Reach for physically separate read and write stores only when a specific read pattern has a load profile or a shape that genuinely can't be served well from your transactional tables, and you've confirmed that with a real, measured bottleneck rather than a guess about future scale.

Where teams get hurt

The most common mistake is applying CQRS uniformly across an entire application. Once a team adopts @nestjs/cqrs, there's a pull toward wrapping every single controller action in a command or query, even a straightforward findById on a Users table. That's pure ceremony: three extra files (the query, the handler, the registration) for a lookup that a one-line repository method already handled fine. Apply the pattern module by module, where the read and write shapes have actually diverged, and leave the rest as plain services.

The second mistake is losing traceability. A command handler that publishes five events, each triggering another handler that publishes more events, becomes very hard to step through in a debugger or reason about from a stack trace. Keep an explicit map, even a rough one in your module's README, of what commands trigger what events and what those events fan out to. Without it, onboarding a new engineer to a CQRS-heavy module takes far longer than it should.

The third: forgetting transactional boundaries. A command handler that writes to the database and then publishes an event isn't atomic unless you make it so. If the process crashes between the write committing and the event publishing, you've got a state change with no corresponding side effect, and no built-in way to know it happened. @nestjs/cqrs's in-memory event bus doesn't persist anything by default. If losing an event actually breaks something for you, that's a sign you need the outbox pattern rather than a bigger retry loop bolted onto the event bus.

Finally, testing gets more indirect once handlers are dispatched through CommandBus and QueryBus rather than called directly. Unit test the handlers themselves in isolation, calling execute() directly with a constructed command, rather than testing through the bus. Reserve integration tests for verifying the wiring, that a POST actually resolves to the handler you expect.

Key takeaways

  • CQRS separates the code that writes state from the code that reads it, using distinct command and query objects with their own handlers.
  • It earns its cost when read and write shapes have genuinely diverged, not as a default architecture for CRUD operations.
  • `@nestjs/cqrs` gives you `CommandBus`, `QueryBus`, and `EventBus` out of the box, wired through `CqrsModule`.
  • CQRS doesn't require two separate databases. One PostgreSQL instance with a code-level split between read and write paths is a valid, much simpler implementation.
  • Apply the pattern module by module where it solves a real problem, and keep plain services for straightforward CRUD.
  • Watch transactional boundaries between a command's write and its published event; the in-memory bus doesn't persist anything on its own.

Frequently asked questions

Do I need CQRS in every NestJS module?

No. Apply it only where read and write shapes have genuinely diverged, such as a dashboard reading a denormalized view while writes enforce strict domain invariants. Most CRUD modules are better served by a plain service.

Does CQRS require event sourcing?

No, they're separate patterns that are often combined but don't require each other. You can use command and query handlers with a normal database table as your source of truth, with no event store involved.

What's the difference between a command and an event in @nestjs/cqrs?

A command represents an intent to do something and has exactly one handler that executes it (`UpgradeSubscriptionCommand`). An event represents something that already happened and can have zero, one, or many handlers reacting to it (`SubscriptionUpgradedEvent`).

Can I use CQRS with a single PostgreSQL database?

Yes, and for most teams that's the right starting point. Command handlers write through your normal tables, query handlers read from those same tables or from views built for specific read shapes. Physically separate read and write stores are a further step you take only when you've confirmed you need it.

How do I keep a write and its side effects consistent if the process crashes?

The in-memory `EventBus` in `@nestjs/cqrs` doesn't persist events, so a crash between the database commit and event publication can silently drop a side effect. If that's unacceptable for your domain, look at the outbox pattern, which writes the event to the same transaction as the state change and delivers it separately.

Is CQRS the same thing as microservices?

No. CQRS is a code-organization pattern you can apply inside a single module of a monolith. Microservices are a deployment and ownership boundary. You can use CQRS with zero microservices, and plenty of microservice architectures don't use CQRS at all.

Related articles

Modules and Dependency Injection — Aman Kumar Singh
A NestJS Production Checklist — Aman Kumar Singh
Unit Testing Services — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.