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.
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 |
|---|---|---|
|
The publisher that dispatches commands to the system. |
|
|
The publisher used for events; replace it to forward events to a message broker. |
|
|
The publisher used for queries. |
|
|
The publisher used for the |
|
|
Retrieves the unique id of an event instance (used to match events to handlers). |
|
|
Rethrow exceptions from event handlers and sagas instead of publishing them to the |
|
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.
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.
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();
}
}
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 };
}
}
Source: NestJS documentation: CQRS, "Commands" and Events.
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:
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>
}
}
Source: NestJS documentation: CQRS, "Commands" and Controllers.
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.
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:
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.
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:
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/catchor 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.
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 secondcommit()produces no extra publisher call. -
Setting
autoCommit = trueon the aggregate publishes each event as soon as it is applied, with nocommit(). 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()anduncommit()let you read and discard the queue, for example to publish events yourself inside a database transaction. -
AggregateRootalso supportsloadFromHistory()and, for each event, an optionalon<EventName>()method thatapply()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.
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()andpublishAll()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, andawait 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()andpublishAll()accept the same dispatcher context as their second argument, before the optionalAsyncContext.
A custom publisher implements IEventPublisher, and the context arrives as its second argument:
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);
}
}
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.
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)),
);
}
Source: NestJS documentation: CQRS, "Sagas".
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/cqrsis 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.
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
LoanOpenedEventand draining the worker leaves instanceloan-L1suspended, waiting for the signal. PublishingBookReturnedEventforL1and draining again completes it with output"returned". -
Publishing
LoanOpenedEventforL2, 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 anidfunction, derived from the business key, so an event published twice finds the instance the first one started.@SignalOn()has an optionalidthat deduplicates a repeated signal, andkeycorrelates the signal with the instance waiting on it. -
A step runs at least once. Pass the step’s
idempotencyKeyto 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 }), orawait 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: falsetoWorkflowsModule.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.
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().
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 };
}
}
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 toEventPublisher.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 withAsyncContext.merge(request, command)orrequest.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>andQuery<T>, soexecute()is typed end to end; untypedanyresults from the buses are no longer necessary. -
EventPublisher.mergeObjectContext()pluscommit()is still the way to publish aggregate events, but since 12.1commit()can be awaited and can carry a transaction.
What changed recently
-
@nestjs/cqrs12.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 awaitablecommit(),publish()andpublishAll()that the durable-saga integration relies on. -
The durable-saga integration lives in the pre-1.0
@nestjs/workflowspackage (0.0.1, published 2026-10-01), so its API may still change. -
UnhandledExceptionBusand therethrowUnhandledoption exist for exceptions from event handlers and sagas; the options also exposecommandPublisher,eventPublisher,queryPublisherandunhandledExceptionPublisherfor replacing the in-memory transport.