CQRS

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm 12.1.2), @nestjs/cqrs 12.1 (npm 12.1.0, published 2026-09-29), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. The durable-saga section also uses @nestjs/workflows 0.0.1, which is a pre-1.0 package. The examples use the Nest 12 CLI project layout (ESM, Vitest, supertest, RxJS 7.8), and the HTTP responses and test results shown are what they produce.

CQRS (Command and Query Responsibility Segregation) separates the operations that change state (commands) from the operations that read it (queries), and lets each side use its own model. @nestjs/cqrs is Nest’s lightweight, in-process implementation of the pattern: three buses (commands, queries and events), decorators that register handlers, a base class for aggregates and an RxJS-based saga mechanism. This page explains how to use it. For the pattern itself (why reads and writes diverge, read models, replication lag) see the CQRS pattern page; for event-sourced aggregates see Event Sourcing; for the distributed saga theory behind the word "saga" see Sagas.

Everything in @nestjs/cqrs runs inside one Node.js process, in memory. It is not a message broker client, it is not a distributed bus, and its sagas are not distributed sagas. Moving messages between services is the job of microservices and their transports; keeping a process alive across restarts is the job of the durable workflows described in Durable sagas (12.1).

The running example is the Bookshelf scenario: borrowing a book opens a loan, returning it closes the loan, and a late return triggers a notice.

Installation and module setup

npm install --save @nestjs/cqrs

Import CqrsModule.forRoot() once, in the root module (or in a feature module, as below). The module is global, so the buses, EventPublisher and UnhandledExceptionBus can be injected anywhere. Handlers are ordinary providers: CqrsModule scans every module for classes decorated with @CommandHandler(), @QueryHandler(), @EventsHandler() and for providers with @Saga() properties, so each handler must be listed in the providers of some module.

A feature module that registers the handlers
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';

@Module({
  imports: [CqrsModule.forRoot()],
  controllers: [LoansController],
  exports: [Journal],
  providers: [
    LoansRepository,
    LoanViews,
    Journal,
    BorrowBookHandler,
    ReturnBookHandler,
    GetLoanHandler,
    LoanOpenedProjection,
    BookReturnedProjection,
    SendLateNoticeHandler,
    LateReturnSagas,
  ],
})
export class LoansModule {}

forRoot() takes an optional options object, and forRootAsync() takes the usual useFactory, useClass, useExisting or useValue forms so the options can come from ConfigService:

Option Purpose Default

commandPublisher

The publisher that dispatches commands to the system.

DefaultCommandPubSub

eventPublisher

The publisher used for events; replace it to forward events to a message broker.

DefaultPubSub

queryPublisher

The publisher used for queries.

DefaultQueryPubSub

unhandledExceptionPublisher

The publisher used for the UnhandledExceptionBus.

DefaultUnhandledExceptionPubSub

eventIdProvider

Retrieves the unique id of an event instance (used to match events to handlers).

DefaultEventIdProvider

rethrowUnhandled

Rethrow exceptions from event handlers and sagas instead of publishing them to the UnhandledExceptionBus.

false

Options from ConfigService
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';

@Module({
  imports: [
    ConfigModule.forRoot(),
    CqrsModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        rethrowUnhandled: config.get<string>('CQRS_RETHROW') === 'true',
      }),
    }),
  ],
})
export class ConfiguredCqrsModule {}

Source: NestJS documentation: CQRS, "Installation" (the options table) and Configuration for ConfigService. With the factory above the module compiles and resolves CommandBus.

The following diagram shows how the pieces fit. A controller only knows the two buses; handlers know the domain; events flow back through the EventBus to projections and sagas.

flowchart LR C[Controller] -->|execute Command| CB[CommandBus] C -->|execute Query| QB[QueryBus] CB --> CH[Command handler] CH -->|load and change| AR[AggregateRoot] AR -->|apply, then commit| EB[EventBus] EB --> EH[Event handlers
read-model projections] EB --> SG[Sagas
events to commands] SG -->|dispatch Command| CB QB --> QH[Query handler] QH -->|reads| RM[(Read model)] EH -->|updates| RM

Commands and the CommandBus

A command asks the system to do something: it is task-based ("borrow this book") rather than data-centric ("set the book’s status column"). A command is a plain class; its handler is registered with @CommandHandler(CommandClass) and implements ICommandHandler. The CommandBus routes each command instance to the handler registered for its class.

A command class may extend Command<T>, a utility class exported by @nestjs/cqrs, to declare the type of its result. The type parameter is erased at runtime; its only job is to make CommandBus.execute() return Promise<T> and to force the handler’s execute() to return a T.

Typed commands
import { Command, Query } from '@nestjs/cqrs';

export class BorrowBookCommand extends Command<{ loanId: string }> {
  constructor(
    public readonly bookId: string,
    public readonly memberId: string,
  ) {
    super();
  }
}

export class ReturnBookCommand extends Command<void> {
  constructor(
    public readonly loanId: string,
    public readonly daysLate = 0,
  ) {
    super();
  }
}
The handler
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
import { randomUUID } from 'node:crypto';

@CommandHandler(BorrowBookCommand)
export class BorrowBookHandler implements ICommandHandler<BorrowBookCommand> {
  constructor(
    private readonly repository: LoansRepository,
    private readonly publisher: EventPublisher,
  ) {}

  // ICommandHandler<BorrowBookCommand> forces the return type declared by Command<T>.
  async execute(command: BorrowBookCommand): Promise<{ loanId: string }> {
    const loan = this.publisher.mergeObjectContext(
      Loan.open(randomUUID(), command.bookId, command.memberId),
    );
    this.repository.save(loan);
    loan.commit(); // hands the queued events to the EventBus
    return { loanId: loan.id };
  }
}

Extending Command<T> is optional. A command that does not extend it is still dispatched, and execute() then returns a loosely typed Promise<any>. The constraint on the handler’s return type applies only to commands that extend Command<T>. Dispatching a command with no registered handler fails with CommandNotFoundException; a handler class that has no execute() method, or that is not decorated with @CommandHandler(), is rejected with InvalidCommandHandlerException.

The controller injects the two buses and nothing else from the domain:

A controller that talks to the buses
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';

@Controller('loans')
export class LoansController {
  constructor(
    private readonly commandBus: CommandBus,
    private readonly queryBus: QueryBus,
  ) {}

  @Post()
  borrow(@Body() body: { bookId: string; memberId: string }) {
    // The result type is inferred from Command<{ loanId: string }>.
    return this.commandBus.execute(new BorrowBookCommand(body.bookId, body.memberId));
  }

  @Post(':id/return')
  @HttpCode(204)
  async giveBack(@Param('id') id: string, @Body() body: { daysLate?: number }): Promise<void> {
    await this.commandBus.execute(new ReturnBookCommand(id, body.daysLate));
  }

  @Get(':id')
  find(@Param('id') id: string) {
    return this.queryBus.execute(new GetLoanQuery(id)); // inferred as Promise<LoanView>
  }
}

Because the controller awaits the command, the handler runs inside the HTTP request. A command handler that throws an HttpException (or any error that an exception filter understands) therefore produces a normal HTTP error response:

curl -s -X POST localhost:3000/loans -H 'content-type: application/json' \
  -d '{"bookId":"b1","memberId":"m1"}'
{"loanId":"e368bf64-f4ba-4a13-b20c-d9e57a47723e"}

Returning the same loan a second time makes ReturnBookHandler throw a ConflictException:

{"message":"Already returned","error":"Conflict","statusCode":409}

Queries and the QueryBus

A query reads state and changes nothing; it is data-centric. The QueryBus works exactly like the CommandBus: query classes may extend Query<T> to declare the result type, and handlers are decorated with @QueryHandler() and implement IQueryHandler.

A typed query and its handler
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';

export class GetLoanQuery extends Query<LoanView> {
  constructor(public readonly loanId: string) {
    super();
  }
}

@QueryHandler(GetLoanQuery)
export class GetLoanHandler implements IQueryHandler<GetLoanQuery> {
  constructor(private readonly views: LoanViews) {}

  async execute({ loanId }: GetLoanQuery): Promise<LoanView> {
    const view = this.views.views.get(loanId);
    if (!view) throw new NotFoundException(`Loan ${loanId} not found`);
    return view;
  }
}

The handler reads the read model (LoanViews), not the aggregate. Keeping the read model current is the job of the event handlers in the next section. Vitest’s expectTypeOf can assert the inferred types: commandBus.execute(new BorrowBookCommand(…​)) is Promise<{ loanId: string }> and queryBus.execute(new GetLoanQuery(…​)) is Promise<LoanView>.

{"loanId":"e368bf64-f4ba-4a13-b20c-d9e57a47723e","bookId":"b1","memberId":"m1","status":"open"}

After POST /loans/{id}/return, the same GET returns "status":"returned", and an unknown id returns 404 with {"message":"Loan nope not found","error":"Not Found","statusCode":404}.

The CommandBus, QueryBus and EventBus are all RxJS Observable streams of what passes through them. You can subscribe to one to log every message or to save events to an event store:

Subscribing to every event
private destroy$ = new Subject<void>();

constructor(private eventBus: EventBus) {
  this.eventBus.pipe(takeUntil(this.destroy$)).subscribe((event) => {
    // save the event, log it, forward it
  });
}

onModuleDestroy() {
  this.destroy$.next();
  this.destroy$.complete();
}

Events, event handlers and the EventBus

An event states that something has happened. Events are plain classes. You can publish one directly with EventBus.publish() (or publishAll() for an array), or let an aggregate publish its events.

Events
export class LoanOpenedEvent {
  constructor(
    public readonly loanId: string,
    public readonly bookId: string,
    public readonly memberId: string,
  ) {}
}

export class BookReturnedEvent {
  constructor(
    public readonly loanId: string,
    public readonly daysLate: number,
  ) {}
}

An event can have any number of handlers, each decorated with @EventsHandler(EventClass) and implementing IEventHandler. A typical use is a projection that updates the read model:

Event handlers that maintain the read model
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';

@EventsHandler(LoanOpenedEvent)
export class LoanOpenedProjection implements IEventHandler<LoanOpenedEvent> {
  constructor(private readonly views: LoanViews) {}
  handle(event: LoanOpenedEvent): void {
    this.views.views.set(event.loanId, {
      loanId: event.loanId,
      bookId: event.bookId,
      memberId: event.memberId,
      status: 'open',
    });
  }
}

@EventsHandler(BookReturnedEvent)
export class BookReturnedProjection implements IEventHandler<BookReturnedEvent> {
  constructor(private readonly views: LoanViews) {}
  handle(event: BookReturnedEvent): void {
    const view = this.views.views.get(event.loanId);
    if (view) view.status = 'returned';
  }
}

Event handlers run outside the HTTP request context. The official documentation spells out the consequences:

  • errors thrown in command handlers can still be caught by the built-in exception filters, because the controller awaits the command;

  • errors thrown in event handlers cannot, so you handle them yourself (see Unhandled exceptions), with a try/catch or with a saga that dispatches a compensating command;

  • the value a command handler returns can go back to the client in the HTTP response;

  • event handlers cannot send HTTP responses; to push information to the client use WebSockets or server-sent events.

Because the in-memory bus does not wait for event handlers, a query issued immediately after a command may or may not see the projection’s update. The default in-memory publisher runs synchronous handlers during publish(), which is why a GET right after the POST sees the loan. With an asynchronous publisher or an asynchronous handler, design for eventual consistency instead, as the CQRS pattern page explains for replication lag.

Aggregates: AggregateRoot and EventPublisher

An aggregate that extends AggregateRoot records domain events with apply(). The events are queued; nothing reaches the EventBus until commit() is called. The aggregate itself knows nothing about the bus, so the command handler connects them with EventPublisher.mergeObjectContext(), which adds working publish() and publishAll() methods to the instance.

An aggregate that raises events
import { AggregateRoot } from '@nestjs/cqrs';

export class Loan extends AggregateRoot {
  private returned = false;

  constructor(
    readonly id: string,
    readonly bookId: string,
    readonly memberId: string,
  ) {
    super();
  }

  static open(id: string, bookId: string, memberId: string): Loan {
    const loan = new Loan(id, bookId, memberId);
    loan.apply(new LoanOpenedEvent(id, bookId, memberId)); // queued until commit()
    return loan;
  }

  giveBack(daysLate: number): void {
    if (this.returned) {
      throw new Error(`Loan ${this.id} is already returned`);
    }
    this.returned = true;
    this.apply(new BookReturnedEvent(this.id, daysLate));
  }

  get isReturned(): boolean {
    return this.returned;
  }
}

Source: NestJS documentation: CQRS, "Events". BorrowBookHandler above shows mergeObjectContext() followed by commit(); ReturnBookHandler does the same for giveBack().

Details worth knowing:

  • commit() publishes every queued event and clears the queue as soon as it hands them to the event bus, so calling it twice never publishes the same events twice. A second commit() produces no extra publisher call.

  • Setting autoCommit = true on the aggregate publishes each event as soon as it is applied, with no commit(). An applied event reaches the publisher immediately.

  • mergeClassContext(Loan) merges the publisher into a class instead of an instance, so every instance can publish without a further call.

  • getUncommittedEvents() and uncommit() let you read and discard the queue, for example to publish events yourself inside a database transaction.

  • AggregateRoot also supports loadFromHistory() and, for each event, an optional on<EventName>() method that apply() calls, which is the hook for rebuilding state when you event-source an aggregate (see Event Sourcing).

Aggregates with an existing base class

If the domain already has a base class, extending AggregateRoot may not be possible. @nestjs/cqrs supports three approaches: extend AggregateRoot (above); apply the WithAggregateRoot() mixin to the existing base class; or implement the IAggregateRoot interface yourself to keep the domain layer free of framework imports. All three work with EventPublisher.

The mixin
import { WithAggregateRoot } from '@nestjs/cqrs';

abstract class AuditedEntity {
  constructor(protected readonly id: string) {}
}

export class Reservation extends WithAggregateRoot(AuditedEntity) {
  confirm(bookId: string, memberId: string) {
    this.apply(new LoanOpenedEvent(this.id, bookId, memberId));
  }
}

Source: NestJS documentation: CQRS, "Flexible Aggregate Roots". On a Reservation, apply() queues one uncommitted event.

What changed in 12.1: dispatcher context and awaitable commit

Before 12.1, an aggregate’s commit() returned nothing, so a command handler could not wait for an asynchronous publisher (one that writes events to a database or a broker) and could not hand it a transaction. As of @nestjs/cqrs 12.1:

  • commit(), publish() and publishAll() on a model return what the event bus returns, which is whatever the event publisher returns. The default in-memory publisher returns nothing; an asynchronous publisher can return a promise, and await aggregate.commit() then waits for it and throws when it fails.

  • All three take an optional dispatcher context, an opaque value the event publisher receives alongside the event. The typical use is the command handler’s database transaction.

  • EventBus.publish() and publishAll() accept the same dispatcher context as their second argument, before the optional AsyncContext.

A custom publisher implements IEventPublisher, and the context arrives as its second argument:

A publisher that sees the dispatcher context
import { IEvent, IEventPublisher } from '@nestjs/cqrs';
import { Subject } from 'rxjs';

export class RecordingPublisher implements IEventPublisher {
  readonly calls: { event: IEvent; context: unknown }[] = [];
  constructor(private readonly subject$: Subject<IEvent>) {}
  publish(event: IEvent, dispatcherContext?: unknown) {
    this.calls.push({ event, context: dispatcherContext });
    this.subject$.next(event);
  }
}
Passing a transaction through commit()
eventBus.publisher = new RecordingPublisher(new Subject());
const loan = eventPublisher.mergeObjectContext(Loan.open('l1', 'b', 'm'));
await loan.commit({ transaction: 'tx-1' });
// the publisher received { transaction: 'tx-1' } as the dispatcher context

Source: NestJS documentation: CQRS, "Events" (the 12.1 notes on commit() and the dispatcher context). The publisher receives the contexts [ { transaction: 'tx-1' } ]. In real code the context would be your ORM’s transaction object, for a publisher that writes through it (see Outbox & Durable Workflows).

If an awaited commit() fails, roll back and run the command again with a freshly loaded aggregate, because the events were already cleared.

Sagas

In @nestjs/cqrs, a saga is an RxJS stream transformer: it receives an Observable of every event on the bus and returns an Observable of commands. Each command the saga emits is dispatched asynchronously by the CommandBus. The @Saga() decorator marks a property (not a method) of an injectable class, and the ofType() operator filters the stream by event class.

A saga that turns a late return into a command
import { Injectable } from '@nestjs/common';
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
import { filter, map, Observable } from 'rxjs';

@Injectable()
export class LateReturnSagas {
  @Saga()
  lateReturn = (events$: Observable<IEvent>): Observable<ICommand> =>
    events$.pipe(
      ofType(BookReturnedEvent),
      filter((event) => event.daysLate > 0),
      map((event) => new SendLateNoticeCommand(event.loanId)),
    );
}

Registering LateReturnSagas as a provider is enough. Returning a book three days late through POST /loans/{id}/return with {"daysLate":3} dispatches SendLateNoticeCommand, and its handler appends late notice for <loan id> to an in-memory journal; an on-time return dispatches nothing.

In-process sagas are not distributed sagas

The word "saga" means two different things, and mixing them up is a common mistake (including in the book this section was cross-checked against).

  • A distributed saga, as described in Sagas, coordinates local transactions across services with persistent state and compensating actions, and survives a crash of any participant.

  • A @Saga() in @nestjs/cqrs is an in-memory event-to-command mapping inside one process. It keeps no state across restarts, has no persistence, no timers that survive a deploy, no retries and no compensation logic of its own. If the process stops between an event and the command a saga would dispatch, the command is never dispatched. A saga that waits for two events (for example a payment after an order, giving up after an hour) loses the first event on a restart. An event published just before the transaction behind it rolls back still reaches the saga.

Use @Saga() for reactions where losing in-flight work on a restart is acceptable. For processes that must survive restarts, use durable sagas (next section).

Sagas are always singletons. Registering a saga in a non-singleton provider throws UnsupportedSagaScopeException. Exceptions thrown by sagas, and by command handlers executing commands that a saga dispatched, go to the UnhandledExceptionBus (see Unhandled exceptions).

Durable sagas (12.1)

Since @nestjs/cqrs 12.1, the official recipe documents durable sagas: instead of keeping process state in RxJS operators, events start and signal durable workflows stored in your database. This is provided by the @nestjs/workflows/cqrs entry point of @nestjs/workflows, a separate package that is pre-1.0 (0.0.1 at the time of writing, with the entry point present in the published package). The @nestjs/cqrs package itself contains no durable-saga code; the 12.1 changes it contributes are the dispatcher context and the awaitable commit() described above, which let the workflow be created in the same transaction as your own writes. @nestjs/workflows declares @nestjs/cqrs 11 or 12 as an optional peer dependency. The package is covered in Outbox & Durable Workflows; this section shows only the CQRS integration.

npm install --save @nestjs/workflows

Import WorkflowsCqrsModule next to CqrsModule and WorkflowsModule. Until you register a store, WorkflowsModule keeps instances in memory. With NODE_ENV=production, startup fails unless allowInMemoryStorage is set, because an in-memory store loses every running instance on a restart; production applications register the package’s PostgreSQL or MySQL store on the database client they already use.

Mapping events to a workflow
import { Module } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
import {
  Workflow,
  WorkflowSignal,
  WorkflowsModule,
  type WorkflowContext,
  type WorkflowRunner,
} from '@nestjs/workflows';
import { SignalOn, StartOn, WorkflowsCqrsModule } from '@nestjs/workflows/cqrs';

// The signal carries the payload type to both ends.
export const bookReturned = new WorkflowSignal<BookReturnedEvent>('book.returned');

// Opening a loan starts one durable instance; returning the book wakes it.
@Workflow('loan-due-date')
@StartOn(LoanOpenedEvent, {
  id: (event) => `loan-${event.loanId}`, // a repeated event finds the same instance
  input: (event) => ({ loanId: event.loanId }),
})
@SignalOn(BookReturnedEvent, {
  signal: bookReturned,
  key: (event) => event.loanId,
})
export class LoanDueDateWorkflow implements WorkflowRunner<{ loanId: string }, 'returned' | 'late-notice-sent'> {
  constructor(private readonly commandBus: CommandBus) {}

  async run(ctx: WorkflowContext, { loanId }: { loanId: string }) {
    // Parks the instance in the store for up to 14 days: no memory, timer or worker is held.
    const returned = await ctx.waitForSignal('await-return', bookReturned, {
      key: loanId,
      timeout: '14d',
    });
    if (returned) return 'returned' as const;

    await ctx.step('late-notice', ({ idempotencyKey }) =>
      this.commandBus.execute(new SendLateNoticeCommand(loanId)).then(() => idempotencyKey),
    );
    return 'late-notice-sent' as const;
  }
}

@Module({
  imports: [
    LoansModule, // already imports CqrsModule.forRoot()
    // allowInMemoryStorage is for demos: register a PostgreSQL or MySQL store in production.
    WorkflowsModule.forRoot({ allowInMemoryStorage: true }),
    WorkflowsCqrsModule,
  ],
  providers: [LoanDueDateWorkflow],
})
export class DurableLoansModule {}

With the worker disabled and a ManualWorkflowClock so that time can be advanced by hand (the testing approach the workflows documentation describes), two scenarios show the behaviour:

  • Publishing LoanOpenedEvent and draining the worker leaves instance loan-L1 suspended, waiting for the signal. Publishing BookReturnedEvent for L1 and draining again completes it with output "returned".

  • Publishing LoanOpenedEvent for L2, then advancing the clock by 15 days and draining, times the wait out: the instance completes with "late-notice-sent" and the late-notice command has executed.

Points to remember:

  • @StartOn() requires an id function, derived from the business key, so an event published twice finds the instance the first one started. @SignalOn() has an optional id that deduplicates a repeated signal, and key correlates the signal with the instance waiting on it.

  • A step runs at least once. Pass the step’s idempotencyKey to whatever the command calls, so a retried step cannot repeat the effect.

  • To make the workflow start and the order commit together, publish inside the database transaction with the transaction as the dispatcher context: eventBus.publish(event, { transaction: tx }), or await aggregate.commit({ transaction: tx }) (12.1). Both resolve once the workflows are started and signalled. Without a transaction in the dispatcher context, a rollback leaves the started workflow behind and a crash before it is written loses it; the workflows documentation tabulates the guarantee of each way of publishing.

  • Event handlers and sagas still receive every event in memory, after the workflows. Inside a transaction they run before it commits, so they can react to an event whose transaction later rolls back.

  • A process that only places orders can pass worker: false to WorkflowsModule.forRoot() so it starts and signals workflows while other processes run them.

A saga that remembers events, such as one that waits for a payment and gives up after an hour, is a workflow in disguise. Start the workflow from the first event, map the others to signals and let waitForSignal() with a timeout do the waiting, durably. A saga that turns one event into one command can stay a @Saga().

Unhandled exceptions

Event handlers run asynchronously and nobody awaits them, so an exception they throw cannot reach the caller. When an event handler throws, the EventBus logs the error, builds an UnhandledExceptionInfo object ({ exception, cause }, where cause is the event or command being processed) and pushes it to the UnhandledExceptionBus. The same applies to exceptions thrown by sagas and by command handlers executing commands that sagas dispatched. The bus is an Observable; subscribe to it to report failures, publish a compensating event or terminate the process. With rethrowUnhandled: true, these exceptions are rethrown instead of being published to the bus.

Reporting unhandled exceptions
import { Injectable, OnModuleDestroy } from '@nestjs/common';
import { EventsHandler, IEventHandler, UnhandledExceptionBus, UnhandledExceptionInfo } from '@nestjs/cqrs';
import { Subject, takeUntil } from 'rxjs';

export class MailServerDownError extends Error {}

// An event handler that fails: the HTTP request that published the event never sees this error.
@EventsHandler(LoanOpenedEvent)
export class WelcomeMailHandler implements IEventHandler<LoanOpenedEvent> {
  handle(event: LoanOpenedEvent): void {
    if (event.memberId === 'flaky') throw new MailServerDownError('SMTP unreachable');
  }
}

@Injectable()
export class FailureReporter implements OnModuleDestroy {
  readonly seen: UnhandledExceptionInfo[] = [];
  private readonly destroy$ = new Subject<void>();

  constructor(unhandledExceptionBus: UnhandledExceptionBus) {
    unhandledExceptionBus
      .pipe(
        takeUntil(this.destroy$),
        UnhandledExceptionBus.ofType(MailServerDownError), // matches the thrown exception with instanceof
      )
      .subscribe((info) => this.seen.push(info));
  }

  onModuleDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

POST /loans for member flaky still returns 201, because the loan is opened and the failure happens in a handler nobody awaits. The reporter collects exactly one entry, whose exception.message is SMTP unreachable and whose cause is the LoanOpenedEvent; Nest also logs "WelcomeMailHandler" has thrown an unhandled exception. at error level.

UnhandledExceptionBus.ofType() filters with instanceof on the exception (the class you pass is the exception class, such as MailServerDownError), not on the event that caused it. Always unsubscribe with takeUntil() in onModuleDestroy(), as above, so the subscription does not outlive the application.

Request scoping

Most Nest providers are singletons, and so are the three buses. Request-scoped handlers are still possible, with some extra rules, because the HTTP request is not the only thing that can trigger a command: a scheduled job or a message consumer can too. @nestjs/cqrs therefore builds a new instance of a request-scoped handler for each command, query or event processed, and uses an AsyncContext object as the "request" that handler sees.

A handler becomes request-scoped when it depends on a request-scoped provider or when you set the scope explicitly in the decorator: @CommandHandler(Command, { scope: Scope.REQUEST }), and likewise for @QueryHandler() and @EventsHandler(). To receive the context, inject REQUEST (from @nestjs/core) and type it as a class that extends AsyncContext. Pass an instance of that class as the second argument of CommandBus.execute() or QueryBus.execute().

A request-scoped command handler
import { Inject, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AsyncContext, Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';

// The "request" of a CQRS handler is whatever you pass as the second argument of execute().
export class MemberContext extends AsyncContext {
  constructor(public readonly memberId: string) {
    super();
  }
}

export class RenewLoanCommand extends Command<{ renewedBy: string; instance: number }> {
  constructor(public readonly loanId: string) {
    super();
  }
}

let instances = 0;

@CommandHandler(RenewLoanCommand, { scope: Scope.REQUEST })
export class RenewLoanHandler implements ICommandHandler<RenewLoanCommand> {
  private readonly instance = ++instances;

  constructor(
    @Inject(REQUEST) private readonly context: MemberContext,
    private readonly journal: Journal,
  ) {}

  async execute({ loanId }: RenewLoanCommand) {
    this.journal.entries.push(`${this.context.memberId} renewed ${loanId}`);
    return { renewedBy: this.context.memberId, instance: this.instance };
  }
}
Executing with a context
const [a, b] = await Promise.all([
  commandBus.execute(new RenewLoanCommand('l1'), new MemberContext('ann')),
  commandBus.execute(new RenewLoanCommand('l1'), new MemberContext('bob')),
]);
// a: { renewedBy: 'ann', instance: 1 }   b: { renewedBy: 'bob', instance: 2 }

Source: NestJS documentation: CQRS, "Request-scoping" and Injection scopes. Two concurrent commands print { renewedBy: 'ann', instance: 1 } { renewedBy: 'bob', instance: 2 }: they get two handler instances, each with its own context. Request scope bubbles up and costs one instantiation per message, as explained in Injection Scopes; use it only when per-request state is needed, such as the current user or tenant.

Notes for events and sagas:

  • For events, either pass the context as an argument of EventBus.publish() (less common) or give it to EventPublisher.mergeObjectContext(aggregate, context). Request-scoped event handlers then see it.

  • Sagas are always singletons. They can still read the context from an event with AsyncContext.of(event) and carry it to the command they emit with AsyncContext.merge(request, command) or request.attachTo(command).

Testing

Handlers are plain providers, so unit tests construct them directly with fakes. To test the wiring, build a Test.createTestingModule() that imports the feature module, call app.init() (the buses register handlers during initialization) and drive it with supertest or by calling the buses. Cover each flow on this page that way: borrow, query, return, saga, failing handler and request scope. See Testing for the general approach.

Where the book is outdated

  • Sagas are in-process RxJS streams, not distributed sagas, and not durable. The book’s treatment of CQRS sagas as a distributed coordination mechanism should not be followed; use durable workflows or a real saga implementation for processes that cross services or restarts.

  • Current code declares the result of a command or query with Command<T> and Query<T>, so execute() is typed end to end; untyped any results from the buses are no longer necessary.

  • EventPublisher.mergeObjectContext() plus commit() is still the way to publish aggregate events, but since 12.1 commit() can be awaited and can carry a transaction.

What changed recently

  • @nestjs/cqrs 12.0.0 (2026-08-27) targets NestJS 12 and requires Node.js 20.19+ or 22.12+; 12.1.0 (2026-09-29) added the dispatcher context and the awaitable commit(), publish() and publishAll() that the durable-saga integration relies on.

  • The durable-saga integration lives in the pre-1.0 @nestjs/workflows package (0.0.1, published 2026-10-01), so its API may still change.

  • UnhandledExceptionBus and the rethrowUnhandled option exist for exceptions from event handlers and sagas; the options also expose commandPublisher, eventPublisher, queryPublisher and unhandledExceptionPublisher for replacing the in-memory transport.