@nestjs-transactional/cqrs
v2.0.0
Published
@nestjs/cqrs integration for @nestjs-transactional/core — transactional event listeners with phases, handler wrapping, AggregateRoot integration
Maintainers
Readme
@nestjs-transactional/cqrs
Transactions and Spring-style event phases for
@nestjs/cqrs.
It solves the race everyone hits with domain events: an aggregate emits
an event, a handler reacts, and then the transaction rolls back — the
side effect already happened. Here, event handlers declare when they
run relative to the commit, and AFTER_COMMIT means the row really is
in the database.
@Injectable()
@TransactionalEventsHandler(OrderPlacedEvent) // AFTER_COMMIT by default
export class NotifyCustomer implements ITransactionalEventHandler<OrderPlacedEvent> {
async handle(event: OrderPlacedEvent) {
// The order is committed and visible. Safe to send the email.
}
}Command and query handlers get transactions by decoration, and
@nestjs/cqrs is used as-is — not forked, not patched.
Built on
@nestjs-transactional/core.
Pair with
@nestjs-transactional/outbox
when a handler must survive a process crash.
Install
pnpm add @nestjs-transactional/cqrs @nestjs-transactional/core @nestjs/cqrsModule format
This package ships ESM only, matching NestJS 12, which is ESM-only across its own packages. There is no CommonJS build.
A CommonJS application still works: Node loads ESM from require()
since 22.12.0, which is why engines.node is >=22.13.0. What does not
follow Node here is tooling with its own module loader — Jest above all,
which needs NODE_OPTIONS=--experimental-vm-modules and a few config
settings. The 19 example applications in the repository all run their
suites that way and can be copied from.
Reasoning and measurements: ADR-022.
Quick start
@Module({
imports: [
TransactionalModule.forRoot({ isGlobal: true }),
TypeOrmTransactionalModule.forRoot(),
CqrsTransactionalModule.forRoot(),
],
})
export class AppModule {}Do not import
CqrsModuleas well. This module imports it internally and overrides theEventPublishertoken. A second import in your app shadows that override, and aggregate events silently stop reaching the dispatcher — no error, just handlers that never fire.
Then a command handler, transactional by decoration:
@CommandHandler(PlaceOrderCommand)
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
constructor(
private readonly publisher: EventPublisher,
private readonly orders: OrderRepository,
) {}
@Transactional()
async execute(command: PlaceOrderCommand) {
const order = this.publisher.mergeObjectContext(new Order(command.orderId));
order.place();
await this.orders.save(order);
order.commit(); // events become hooks on this transaction
}
}order.commit() does not dispatch immediately. Each event attaches to
the current transaction at its handler's phase, so the commit decides
what runs.
Event phases
| Phase | Fires | If the handler throws |
| --- | --- | --- |
| BEFORE_COMMIT | before COMMIT is issued | the transaction rolls back |
| AFTER_COMMIT (default) | after COMMIT succeeds | logged and swallowed |
| AFTER_ROLLBACK | after ROLLBACK, with the causing error | logged and swallowed |
| AFTER_COMPLETION | on either outcome | logged and swallowed |
@TransactionalEventsHandler({
events: [OrderPlacedEvent],
phase: TransactionPhase.AFTER_ROLLBACK,
})Two flags worth knowing: fallbackExecution: true makes a handler fire
even when the event is published outside any transaction (otherwise such
events are dropped with a warning), and async: true fires it through
queueMicrotask so its errors can never reach the rollback path.
What gets wrapped
CqrsTransactionalModule.forRoot() wraps handlers at bootstrap:
- Command handlers carrying
@Transactional()(method- or class-level). SetdefaultCommandOptionsto wrap them all. - Query handlers — wrapped read-only by default
(
defaultQueryOptions: { readOnly: true }). Passundefinedto opt out. Note thatreadOnlyis enforced by the database only on Postgres-family dialects. - Event handlers only when they carry
@Transactional(). There is no kind-level default, because event handlers are often out-of-band side effects where a transaction is the wrong thing.
Async configuration works the same way, with one wrinkle:
CqrsTransactionalModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (cfg: ConfigService) => ({ wrapQueryHandlers: cfg.get('WRAP') !== 'false' }),
// Structural, so it stays outside the factory: it decides whether the
// EventPublisher override provider exists at all, and NestJS needs
// provider tokens before any factory has run.
useTransactionalEventPublisher: true,
});Choosing a handler decorator
| | Persisted | Retried | Survives restart |
| --- | --- | --- | --- |
| @TransactionalEventsHandler | no | no | no |
| @OutboxEventsHandler (outbox package) | yes | yes | yes |
| @IntegrationEventsHandler | if the outbox is wired | if wired | if wired |
Use @TransactionalEventsHandler for in-process work that is fine to
lose on a crash — cache invalidation, metrics. Use
@OutboxEventsHandler when at-least-once delivery matters: external
API calls, emails, billing.
@IntegrationEventsHandler is the one to reach for by default in
cross-module code. It routes through the outbox when
OUTBOX_LISTENER_REGISTRAR is bound and falls back to in-memory
delivery when it is not — decided at bootstrap by module wiring, not at
the call site. The same handler therefore runs in-memory during early
development and durably once a worker exists, without touching the
handler. It mirrors Spring Modulith's @ApplicationModuleListener.
To turn on durable delivery, bind both structural ports:
providers: [
{ provide: OUTBOX_PUBLICATION_SCHEDULER, useExisting: OutboxEventPublisher },
{ provide: OUTBOX_LISTENER_REGISTRAR, useExisting: OutboxListenerRegistry },
];A rollback then undoes all of it: no in-memory handler fires, no publication row persists, nothing downstream runs.
Listener ids are ${baseId}#${EventName}, with baseId defaulting to
the class name — so pass an explicit id if the class may be renamed,
or stored publications will be orphaned.
Limitations
eventBus.publish(...)bypasses the dispatcher. Only events emitted by an aggregate throughmergeObjectContext/mergeClassContextandcommit()become phase-aware.- Arrow-function class fields are not wrapped. The wrap point is the
prototype, and
execute = async (q) => {}shadows it. Use method syntax. @nestjs/cqrs@11only, deliberately, while the other peers accept^10 || ^11. The wrapping mechanism would work on v10, butAsyncContext— which request-scoped handler support depends on — does not exist there, and advertising^10would promise a documented feature that cannot work.
Handlers of any scope are supported, including Scope.REQUEST and
Scope.TRANSIENT, because the wrap is applied to the prototype
(ADR-020).
Documentation
- Getting started and full docs
- Transactional events and Spring semantics (ADR-002)
- Handler API design (ADR-014)
- Why
@nestjs/cqrsis not forked (DD-002) - Runnable examples:
basic-cqrs,multi-datasource-cqrs,saga-pattern,e-commerce-orders
License
MIT
