Events & Sagas
Where commands describe what should happen, events record what already happened. After a command handler mutates state it publishes a domain event onto the EventBus, and any number of event handlers react to it — sending email, updating a read model, or emitting metrics. For long-running, multi-step workflows that span several aggregates, NestJS adds sagas: RxJS pipelines that listen to the event stream and dispatch new commands in response. Together they let you build loosely coupled, choreographed processes without tangling business logic into a single handler.
Defining and publishing an event
An event is a plain, immutable class describing a fact in the past tense. Implementing IEvent from @nestjs/cqrs documents intent and keeps handlers strongly typed. Carry only the data subscribers need — ids and the values that changed.
// events/user-created.event.ts
import { IEvent } from '@nestjs/cqrs';
export class UserCreatedEvent implements IEvent {
constructor(
public readonly userId: string,
public readonly email: string,
) {}
}
Publish it from a command handler (or any provider) by injecting the EventBus and calling publish. Unlike commands, an event may have zero, one, or many handlers — the bus fans it out to all of them.
// commands/create-user.handler.ts (excerpt)
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
import { CreateUserCommand } from './create-user.command';
import { UserRepository } from '../user.repository';
import { UserCreatedEvent } from '../events/user-created.event';
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
constructor(
private readonly users: UserRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: CreateUserCommand): Promise<void> {
const user = await this.users.save({
email: command.email,
displayName: command.displayName,
});
this.eventBus.publish(new UserCreatedEvent(user.id, user.email));
}
}
Handling events
An event handler is a provider decorated with @EventsHandler(EventClass) that implements IEventHandler<TEvent>. Its single handle method receives the event instance. Handlers run independently, so a failure in one does not stop the others — keep each one focused on a single side effect.
// events/send-welcome-email.handler.ts
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { UserCreatedEvent } from './user-created.event';
import { MailService } from '../mail.service';
@EventsHandler(UserCreatedEvent)
export class SendWelcomeEmailHandler
implements IEventHandler<UserCreatedEvent>
{
constructor(private readonly mail: MailService) {}
async handle(event: UserCreatedEvent): Promise<void> {
await this.mail.send(event.email, 'welcome', { userId: event.userId });
}
}
You can register the same event on multiple handlers — for example one updating a projection and another publishing analytics. @EventsHandler also accepts several event classes when one handler should react to a family of events: @EventsHandler(UserCreatedEvent, UserUpdatedEvent).
Event handlers are fire-and-forget. The
publishcall does not await them, so never rely on a handler’s result for the originating request. If you need an outcome back, return it from the command handler instead.
Coordinating workflows with sagas
A saga turns the event stream into new commands. Inside a provider class, a method decorated with @Saga receives an Observable<IEvent> of every published event and must return an Observable<ICommand>. You filter for the events you care about with RxJS operators, then map each one to a command that the CommandBus dispatches automatically. This is ideal for choreographing steps across aggregates — “when a user is created, provision their default workspace”.
// sagas/user.saga.ts
import { Injectable } from '@nestjs/common';
import { ICommand, Saga, ofType } from '@nestjs/cqrs';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { UserCreatedEvent } from '../events/user-created.event';
import { ProvisionWorkspaceCommand } from '../commands/provision-workspace.command';
@Injectable()
export class UserSagas {
@Saga()
provisionWorkspace = (
events$: Observable<unknown>,
): Observable<ICommand> => {
return events$.pipe(
ofType(UserCreatedEvent),
map((event) => new ProvisionWorkspaceCommand(event.userId)),
);
};
}
The ofType operator from @nestjs/cqrs narrows the stream to a specific event type and refines the TypeScript type, so event.userId is fully typed inside map. Because the full RxJS toolbox is available, you can debounceTime, filter, mergeMap, or delay to model timeouts, throttling, and conditional branches.
Registering everything
List the CqrsModule, your event handlers, and your saga providers in the module. Sagas are ordinary providers — the framework discovers their @Saga methods at startup.
// user.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { SendWelcomeEmailHandler } from './events/send-welcome-email.handler';
import { UserSagas } from './sagas/user.saga';
export const EventHandlers = [SendWelcomeEmailHandler];
export const Sagas = [UserSagas];
@Module({
imports: [CqrsModule],
providers: [...EventHandlers, ...Sagas],
})
export class UserModule {}
Output:
[Nest] LOG [InstanceLoader] CqrsModule dependencies initialized
[Nest] LOG UserCreatedEvent published (userId=6f1c..., [email protected])
[Nest] LOG SendWelcomeEmailHandler -> queued welcome mail for [email protected]
[Nest] LOG Saga provisionWorkspace -> dispatching ProvisionWorkspaceCommand(6f1c...)
Events vs. sagas at a glance
| Concern | Event handler (@EventsHandler) | Saga (@Saga) |
|---|---|---|
| Purpose | React with a side effect | Orchestrate the next step |
| Output | Nothing (void) | An Observable<ICommand> |
| Cardinality | Many handlers per event | One stream, mapped to commands |
| Best for | Email, projections, logging | Multi-step, cross-aggregate flows |
Best Practices
- Name events in the past tense (
OrderShippedEvent) and keep their fieldsreadonly. - Publish events only after the state change has committed, so subscribers never see a phantom fact.
- Keep each event handler responsible for a single side effect; let failures stay isolated.
- Use
ofTyperather than manualfilterchecks — it narrows the type and reads cleanly. - Map events to commands inside sagas instead of doing real work there; sagas decide what next, handlers do it.
- Guard sagas against loops: avoid emitting a command whose event re-triggers the same saga.
- Group handlers and sagas into exported arrays so module registration scales without churn.