@nestjs-transactional/cqrs 1.0.0-alpha.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +155 -410
  2. package/dist/decorators/integration-events-handler.decorator.d.ts +2 -2
  3. package/dist/decorators/integration-events-handler.decorator.js +9 -14
  4. package/dist/decorators/integration-events-handler.decorator.js.map +1 -1
  5. package/dist/decorators/transactional-events-handler.decorator.d.ts +4 -4
  6. package/dist/decorators/transactional-events-handler.decorator.js +12 -17
  7. package/dist/decorators/transactional-events-handler.decorator.js.map +1 -1
  8. package/dist/event-dispatcher/event-dispatcher.d.ts +4 -4
  9. package/dist/event-dispatcher/event-dispatcher.js +18 -22
  10. package/dist/event-dispatcher/event-dispatcher.js.map +1 -1
  11. package/dist/event-publisher/hybrid-event-publisher.d.ts +2 -2
  12. package/dist/event-publisher/hybrid-event-publisher.js +10 -13
  13. package/dist/event-publisher/hybrid-event-publisher.js.map +1 -1
  14. package/dist/event-publisher/transactional-event-publisher-adapter.d.ts +15 -5
  15. package/dist/event-publisher/transactional-event-publisher-adapter.js +20 -12
  16. package/dist/event-publisher/transactional-event-publisher-adapter.js.map +1 -1
  17. package/dist/event-publisher/transactional-event-publisher.d.ts +1 -1
  18. package/dist/event-publisher/transactional-event-publisher.js +6 -9
  19. package/dist/event-publisher/transactional-event-publisher.js.map +1 -1
  20. package/dist/handlers/bootstrap.d.ts +1 -1
  21. package/dist/handlers/bootstrap.js +6 -9
  22. package/dist/handlers/bootstrap.js.map +1 -1
  23. package/dist/handlers/handler-wrapper.js +16 -19
  24. package/dist/handlers/handler-wrapper.js.map +1 -1
  25. package/dist/handlers/integration-events-handler-scanner.d.ts +2 -2
  26. package/dist/handlers/integration-events-handler-scanner.js +19 -22
  27. package/dist/handlers/integration-events-handler-scanner.js.map +1 -1
  28. package/dist/handlers/listener-scanner.d.ts +1 -1
  29. package/dist/handlers/listener-scanner.js +11 -14
  30. package/dist/handlers/listener-scanner.js.map +1 -1
  31. package/dist/handlers/outbox-listener-registrar.d.ts +3 -3
  32. package/dist/handlers/outbox-listener-registrar.js +2 -5
  33. package/dist/handlers/outbox-listener-registrar.js.map +1 -1
  34. package/dist/index.d.ts +15 -15
  35. package/dist/index.js +13 -36
  36. package/dist/index.js.map +1 -1
  37. package/dist/interfaces/integration-event-handler.interface.js +1 -2
  38. package/dist/interfaces/transactional-event-handler.interface.js +1 -2
  39. package/dist/module/cqrs-transactional.module.d.ts +66 -8
  40. package/dist/module/cqrs-transactional.module.js +120 -68
  41. package/dist/module/cqrs-transactional.module.js.map +1 -1
  42. package/dist/types/transactional-listener.types.js +2 -5
  43. package/dist/types/transactional-listener.types.js.map +1 -1
  44. package/package.json +27 -16
package/README.md CHANGED
@@ -1,469 +1,214 @@
1
1
  # @nestjs-transactional/cqrs
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/%40nestjs-transactional%2Fcqrs/alpha?style=flat-square&label=npm)](https://www.npmjs.com/package/@nestjs-transactional/cqrs)
3
+ [![npm version](https://img.shields.io/npm/v/%40nestjs-transactional%2Fcqrs?style=flat-square&label=npm)](https://www.npmjs.com/package/@nestjs-transactional/cqrs)
4
4
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/LICENSE)
5
5
 
6
- Integration between [@nestjs-transactional/core](../core) and
7
- [`@nestjs/cqrs`](https://docs.nestjs.com/recipes/cqrs). Gives
8
- `@CommandHandler` / `@QueryHandler` / `@EventsHandler` classes
9
- declarative transaction management and Spring-style event handler
10
- phases without forking `@nestjs/cqrs` (see ADR-003).
11
-
12
- ## What it provides
13
-
14
- - **`@TransactionalEventsHandler(...events)`** — class-level event
15
- handler decorator with Spring-compatible phases: `BEFORE_COMMIT`,
16
- `AFTER_COMMIT` (default), `AFTER_ROLLBACK`, `AFTER_COMPLETION`. The
17
- decorated class implements `ITransactionalEventHandler<T>` and
18
- exposes a single `handle(event)` method. Matches the ergonomics of
19
- `@nestjs/cqrs`'s own `@EventsHandler` (see ADR-014).
20
- - **`@IntegrationEventsHandler(...events)`** — class-level smart
21
- default for cross-module handlers. Delivers via the outbox when the
22
- `OUTBOX_LISTENER_REGISTRAR` structural port is bound (durable,
23
- retried, resumable), falls back to in-memory `AFTER_COMMIT` + `async:
24
- true` dispatch otherwise. Matches Spring Modulith's
25
- `@ApplicationModuleListener` contract.
26
- - **`TransactionalEventPublisher` + `TransactionalEventPublisherAdapter`** —
27
- drop-in replacement for `@nestjs/cqrs`'s `EventPublisher`.
28
- `AggregateRoot.commit()` routes events through the transactional
29
- dispatcher, so `AFTER_COMMIT` handlers only fire once the
30
- transaction actually commits — no more "event published, then
31
- transaction rolled back" race.
32
- - **`HybridEventPublisher`** — the strategy wired by
33
- `CqrsTransactionalModule.forRoot()` into the `EventPublisher`
34
- override. Routes aggregate events through the in-memory dispatcher
35
- AND, when an outbox scheduler is bound to the
36
- `OUTBOX_PUBLICATION_SCHEDULER` token, also through
37
- `@nestjs-transactional/outbox` for durable delivery. Without
38
- the outbox binding, behaves identically to
39
- `TransactionalEventPublisher`.
40
- - **`CqrsHandlerWrapper` + `CqrsTransactionalBootstrap`** — bootstrap-time
41
- wrapping of every `@CommandHandler` / `@QueryHandler` / `@EventsHandler`
42
- instance that carries `@Transactional()` metadata (method-level or
43
- class-level), or matches kind-specific defaults (e.g. read-only
44
- wrapping for queries).
45
- - **`TransactionalListenerScanner` +
46
- `IntegrationEventsHandlerScanner`** — `OnModuleInit` scanners that
47
- auto-register every `@TransactionalEventsHandler` /
48
- `@IntegrationEventsHandler` class with the appropriate delivery
49
- path.
50
- - **`CqrsTransactionalModule.forRoot({...})`** — single entry point that
51
- wires all of the above.
52
-
53
- Peer dependencies: `@nestjs-transactional/core`, `@nestjs/cqrs ^11`,
54
- `@nestjs/common ^10 || ^11`, `@nestjs/core ^10 || ^11`, `rxjs ^7`,
55
- `reflect-metadata`.
56
-
57
- ## Module configuration
6
+ Transactions and Spring-style event phases for
7
+ [`@nestjs/cqrs`](https://docs.nestjs.com/recipes/cqrs).
58
8
 
59
- ```ts
60
- import { Module } from '@nestjs/common';
61
- import { TransactionalModule } from '@nestjs-transactional/core';
62
- import { TypeOrmTransactionalModule } from '@nestjs-transactional/typeorm';
63
- import { CqrsTransactionalModule } from '@nestjs-transactional/cqrs';
9
+ It solves the race everyone hits with domain events: an aggregate emits
10
+ an event, a handler reacts, and then the transaction rolls back — the
11
+ side effect already happened. Here, event handlers declare *when* they
12
+ run relative to the commit, and `AFTER_COMMIT` means the row really is
13
+ in the database.
64
14
 
65
- @Module({
66
- imports: [
67
- TransactionalModule.forRoot({ isGlobal: true }),
68
- TypeOrmTransactionalModule.forRoot(),
69
- CqrsTransactionalModule.forRoot({
70
- // every option has a sensible default shown here for completeness
71
- wrapCommandHandlers: true,
72
- wrapQueryHandlers: true,
73
- wrapEventHandlers: true,
74
- defaultQueryOptions: { readOnly: true },
75
- // defaultCommandOptions: { propagation: PropagationMode.REQUIRED },
76
- useTransactionalEventPublisher: true,
77
- }),
78
- ],
79
- })
80
- export class AppModule {}
15
+ ```ts
16
+ @Injectable()
17
+ @TransactionalEventsHandler(OrderPlacedEvent) // AFTER_COMMIT by default
18
+ export class NotifyCustomer implements ITransactionalEventHandler<OrderPlacedEvent> {
19
+ async handle(event: OrderPlacedEvent) {
20
+ // The order is committed and visible. Safe to send the email.
21
+ }
22
+ }
81
23
  ```
82
24
 
83
- **Important**: do NOT import `CqrsModule` separately alongside
84
- `CqrsTransactionalModule.forRoot()`. The transactional module imports
85
- `CqrsModule` internally and overrides the `EventPublisher` DI token —
86
- importing `CqrsModule` a second time in the consumer shadows the
87
- override with the original.
25
+ Command and query handlers get transactions by decoration, and
26
+ `@nestjs/cqrs` is used as-is — not forked, not patched.
88
27
 
89
- ## Full example
28
+ Built on
29
+ [`@nestjs-transactional/core`](https://www.npmjs.com/package/@nestjs-transactional/core).
30
+ Pair with
31
+ [`@nestjs-transactional/outbox`](https://www.npmjs.com/package/@nestjs-transactional/outbox)
32
+ when a handler must survive a process crash.
90
33
 
91
- An order placement flow, end-to-end:
34
+ ## Install
92
35
 
93
- ```ts
94
- // aggregate.ts
95
- import { AggregateRoot } from '@nestjs/cqrs';
36
+ ```bash
37
+ pnpm add @nestjs-transactional/cqrs @nestjs-transactional/core @nestjs/cqrs
38
+ ```
96
39
 
97
- export class OrderPlacedEvent {
98
- constructor(public readonly orderId: string) {}
99
- }
40
+ ## Module format
100
41
 
101
- export class Order extends AggregateRoot {
102
- constructor(public readonly id: string) {
103
- super();
104
- }
105
- place(): void {
106
- this.apply(new OrderPlacedEvent(this.id));
107
- }
108
- }
109
- ```
42
+ This package ships **ESM only**, matching NestJS 12, which is ESM-only
43
+ across its own packages. There is no CommonJS build.
110
44
 
111
- ```ts
112
- // order.repository.ts
113
- import { Injectable } from '@nestjs/common';
114
- import { InjectRepository } from '@nestjs/typeorm';
115
- import { Repository } from 'typeorm';
116
- import { OrderRow } from './order.entity';
45
+ A CommonJS application still works: Node loads ESM from `require()`
46
+ since 22.12.0, which is why `engines.node` is `>=22.13.0`. What does not
47
+ follow Node here is tooling with its own module loader — Jest above all,
48
+ which needs `NODE_OPTIONS=--experimental-vm-modules` and a few config
49
+ settings. The 19 example applications in the repository all run their
50
+ suites that way and can be copied from.
117
51
 
118
- @Injectable()
119
- export class OrderRepository {
120
- constructor(
121
- @InjectRepository(OrderRow) private readonly rows: Repository<OrderRow>,
122
- ) {}
52
+ Reasoning and measurements: [ADR-022](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/022-esm-only-packaging.md).
123
53
 
124
- async save(order: { id: string }): Promise<void> {
125
- // The @InjectRepository instance auto-dispatches through the
126
- // active @Transactional() scope's EntityManager — no
127
- // getCurrentEntityManager() boilerplate needed.
128
- await this.rows.save({ id: order.id });
129
- }
130
- }
131
- ```
54
+ ## Quick start
132
55
 
133
56
  ```ts
134
- // place-order.handler.ts
135
- import { CommandHandler, EventPublisher, type ICommandHandler } from '@nestjs/cqrs';
136
- import { Transactional } from '@nestjs-transactional/core';
137
- import { Order } from './aggregate';
138
- import { OrderRepository } from './order.repository';
139
-
140
- export class PlaceOrderCommand {
141
- constructor(public readonly orderId: string) {}
142
- }
57
+ @Module({
58
+ imports: [
59
+ TransactionalModule.forRoot({ isGlobal: true }),
60
+ TypeOrmTransactionalModule.forRoot(),
61
+ CqrsTransactionalModule.forRoot(),
62
+ ],
63
+ })
64
+ export class AppModule {}
65
+ ```
66
+
67
+ > **Do not import `CqrsModule` as well.** This module imports it
68
+ > internally and overrides the `EventPublisher` token. A second import
69
+ > in your app shadows that override, and aggregate events silently stop
70
+ > reaching the dispatcher — no error, just handlers that never fire.
71
+
72
+ Then a command handler, transactional by decoration:
143
73
 
74
+ ```ts
144
75
  @CommandHandler(PlaceOrderCommand)
145
- export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand, void> {
76
+ export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
146
77
  constructor(
147
78
  private readonly publisher: EventPublisher,
148
- private readonly repo: OrderRepository,
79
+ private readonly orders: OrderRepository,
149
80
  ) {}
150
81
 
151
82
  @Transactional()
152
- async execute(command: PlaceOrderCommand): Promise<void> {
83
+ async execute(command: PlaceOrderCommand) {
153
84
  const order = this.publisher.mergeObjectContext(new Order(command.orderId));
154
85
  order.place();
155
- await this.repo.save(order);
156
- order.commit(); // events attach as AFTER_COMMIT hooks on the current tx
86
+ await this.orders.save(order);
87
+ order.commit(); // events become hooks on this transaction
157
88
  }
158
89
  }
159
90
  ```
160
91
 
161
- ```ts
162
- // order.projection.ts
163
- import { Injectable } from '@nestjs/common';
164
- import {
165
- type ITransactionalEventHandler,
166
- TransactionPhase,
167
- TransactionalEventsHandler,
168
- } from '@nestjs-transactional/cqrs';
169
- import { OrderPlacedEvent } from './aggregate';
170
-
171
- @Injectable()
172
- @TransactionalEventsHandler(OrderPlacedEvent)
173
- export class OrderCommittedProjection
174
- implements ITransactionalEventHandler<OrderPlacedEvent>
175
- {
176
- async handle(event: OrderPlacedEvent): Promise<void> {
177
- // Runs AFTER the transaction commits, not before. Safe to do side
178
- // effects here — the DB write is durable.
179
- }
180
- }
92
+ `order.commit()` does not dispatch immediately. Each event attaches to
93
+ the current transaction at its handler's phase, so the commit decides
94
+ what runs.
181
95
 
182
- @Injectable()
183
- @TransactionalEventsHandler({
184
- events: [OrderPlacedEvent],
185
- phase: TransactionPhase.AFTER_ROLLBACK,
186
- })
187
- export class OrderRollbackProjection
188
- implements ITransactionalEventHandler<OrderPlacedEvent>
189
- {
190
- handle(event: OrderPlacedEvent, error?: unknown): void {
191
- // Compensating action; receives the rollback cause as the second
192
- // argument (added beyond the interface signature — TypeScript
193
- // permits widening the parameter list on the implementation).
194
- }
195
- }
196
- ```
96
+ ## Event phases
197
97
 
198
- Note the class-per-reaction shape: `OrderCommittedProjection` reacts
199
- to the AFTER_COMMIT phase, `OrderRollbackProjection` to
200
- AFTER_ROLLBACK. Each class has one `handle` method because each class
201
- has one responsibility see ADR-014 for the rationale.
202
-
203
- What happens when `commandBus.execute(new PlaceOrderCommand('o-1'))` is
204
- dispatched:
205
-
206
- 1. `CqrsHandlerWrapper` has replaced `PlaceOrderHandler.execute` with a
207
- `TransactionManager.run(...)` wrapper at application bootstrap. The
208
- dispatch enters a new transaction.
209
- 2. Inside the wrapped execute, the aggregate's `publishAll` goes through
210
- `TransactionalEventPublisher`, which calls
211
- `TransactionalEventDispatcher.scheduleDispatch(event)`. The
212
- dispatcher attaches `OrderCommittedProjection.handle` as an
213
- `AFTER_COMMIT` hook on the current transaction, and
214
- `OrderRollbackProjection.handle` as an `AFTER_ROLLBACK` hook.
215
- 3. The repository's `@InjectRepository(OrderRow)` Repository
216
- auto-dispatches through the active transaction (the transparent
217
- transactional repository feature in
218
- [`@nestjs-transactional/typeorm`](../typeorm)) — both writes go
219
- through the same DB connection.
220
- 4. `execute` resolves; `TransactionManager` commits the transaction;
221
- the adapter flushes to the database.
222
- 5. After the commit succeeds, the manager runs `AFTER_COMMIT` hooks —
223
- `OrderCommittedProjection.handle` fires once, with a row already
224
- visible in the database.
225
- 6. On a thrown error, step 4 rolls back instead; step 5 runs
226
- `AFTER_ROLLBACK` hooks — `OrderRollbackProjection.handle` fires,
227
- receiving the original error.
228
-
229
- ## Decorator shapes — rest params vs. options object
230
-
231
- Every handler decorator accepts two equivalent forms:
98
+ | Phase | Fires | If the handler throws |
99
+ | --- | --- | --- |
100
+ | `BEFORE_COMMIT` | before COMMIT is issued | the transaction rolls back |
101
+ | `AFTER_COMMIT` *(default)* | after COMMIT succeeds | logged and swallowed |
102
+ | `AFTER_ROLLBACK` | after ROLLBACK, with the causing error | logged and swallowed |
103
+ | `AFTER_COMPLETION` | on either outcome | logged and swallowed |
232
104
 
233
105
  ```ts
234
- // Short form — rest params. Use when defaults are fine.
235
- @TransactionalEventsHandler(OrderPlacedEvent, OrderCancelledEvent)
236
- @OutboxEventsHandler(OrderPlacedEvent)
237
- @IntegrationEventsHandler(OrderPlacedEvent)
238
-
239
- // Long form — options object. Use when you need non-default phase,
240
- // async, fallbackExecution, or a stable listener id.
241
106
  @TransactionalEventsHandler({
242
107
  events: [OrderPlacedEvent],
243
- phase: TransactionPhase.BEFORE_COMMIT,
244
- async: false,
245
- fallbackExecution: true,
246
- })
247
- @IntegrationEventsHandler({
248
- events: [OrderPlacedEvent],
249
- id: 'Inventory.stable-id',
108
+ phase: TransactionPhase.AFTER_ROLLBACK,
250
109
  })
251
110
  ```
252
111
 
253
- ## Handler phases at a glance
254
-
255
- | Phase | When it fires | If handler throws |
256
- |---|---|---|
257
- | `BEFORE_COMMIT` | Before the adapter issues COMMIT | Transaction rolls back |
258
- | `AFTER_COMMIT` *(default)* | After a successful COMMIT | Logged and swallowed |
259
- | `AFTER_ROLLBACK` | After ROLLBACK; receives the causing error as second arg | Logged and swallowed |
260
- | `AFTER_COMPLETION` | On any completion (commit OR rollback) | Logged and swallowed |
261
-
262
- `{ fallbackExecution: true }` makes a handler fire directly (via
263
- `queueMicrotask`) when the event is published outside any transaction.
264
- Otherwise out-of-transaction events are dropped with a warning.
265
-
266
- `{ async: true }` fires the handler via `queueMicrotask` even inside a
267
- transaction — its errors never reach the transaction's rollback path.
268
- Useful for genuinely fire-and-forget side effects.
269
-
270
- ## Defaults baked into `CqrsTransactionalModule.forRoot()`
271
-
272
- - Command handlers are wrapped in `REQUIRED`-propagation transactions.
273
- Without method- or class-level `@Transactional()`, they remain unwrapped
274
- unless `defaultCommandOptions` is provided.
275
- - Query handlers are wrapped as read-only transactions by default
276
- (`defaultQueryOptions: { readOnly: true }`). Pass
277
- `defaultQueryOptions: undefined` to opt out.
278
- - Event handlers are wrapped only if they carry `@Transactional()` (no
279
- kind-level default is applied to events — they are often used for
280
- out-of-band side effects where wrapping is inappropriate).
281
- - `AggregateRoot.commit()` routes events through the dispatcher — set
282
- `useTransactionalEventPublisher: false` to leave `@nestjs/cqrs`'s
283
- standard `EventPublisher` in place (useful for gradual adoption).
284
-
285
- ## Outbox integration
286
-
287
- `CqrsTransactionalModule.forRoot()` always wires `HybridEventPublisher`
288
- into the `EventPublisher` DI override. By default, `HybridEventPublisher`
289
- routes events only through the in-memory dispatcher — no outbox side
290
- effects. To turn on durable delivery, bind BOTH structural ports in
291
- your app module:
292
-
293
- ```ts
294
- import { Module } from '@nestjs/common';
295
- import {
296
- OutboxEventPublisher,
297
- OutboxListenerRegistry,
298
- OutboxModule,
299
- } from '@nestjs-transactional/outbox';
300
- import {
301
- CqrsTransactionalModule,
302
- OUTBOX_LISTENER_REGISTRAR,
303
- OUTBOX_PUBLICATION_SCHEDULER,
304
- } from '@nestjs-transactional/cqrs';
112
+ Two flags worth knowing: `fallbackExecution: true` makes a handler fire
113
+ even when the event is published outside any transaction (otherwise such
114
+ events are dropped with a warning), and `async: true` fires it through
115
+ `queueMicrotask` so its errors can never reach the rollback path.
305
116
 
306
- @Module({
307
- imports: [
308
- // ...the usual wiring — TransactionalModule, a typeorm adapter,
309
- // OutboxTypeOrmModule, OutboxModule, CqrsTransactionalModule...
310
- CqrsTransactionalModule.forRoot(),
311
- ],
312
- providers: [
313
- // Routes AggregateRoot.commit() events to the outbox for durable
314
- // publication.
315
- { provide: OUTBOX_PUBLICATION_SCHEDULER, useExisting: OutboxEventPublisher },
316
- // Routes @IntegrationEventsHandler classes to the outbox registry
317
- // for durable delivery.
318
- { provide: OUTBOX_LISTENER_REGISTRAR, useExisting: OutboxListenerRegistry },
319
- ],
320
- })
321
- export class AppModule {}
322
- ```
117
+ ## What gets wrapped
323
118
 
324
- With both bindings in place, a single `aggregate.commit()` call:
325
-
326
- 1. Attaches one `AFTER_COMMIT` hook per `@TransactionalEventsHandler`
327
- class registered for the event — fires after the transaction
328
- commits, entirely in-memory, no DB rows.
329
- 2. Buffers the event for outbox publication — a single
330
- `beforeCommit` hook per transaction flushes the whole buffer into
331
- `event_publication` rows, atomically with the business write.
332
- 3. Once the transaction commits, the outbox processor (running in
333
- a worker) polls those rows and invokes every
334
- `@OutboxEventsHandler` / `@IntegrationEventsHandler` class
335
- registered for the event.
336
-
337
- Rollback rolls back all three: no in-memory handlers fire, no
338
- publication rows are persisted, nothing downstream runs. This is the
339
- core guarantee of the outbox pattern — "event published only if the
340
- business change landed".
341
-
342
- ## Choosing between handler flavours
343
-
344
- - **`@TransactionalEventsHandler`** — cheap, in-process, phase-aware,
345
- non-durable. Use for side effects that are OK to lose on a crash
346
- between commit and invocation (metrics, cache invalidation,
347
- enrichment of in-memory state).
348
- - **`@OutboxEventsHandler`** *(from outbox)* — durable,
349
- retry-on-failure, resumable-across-restart, delivered by a worker.
350
- Use for integration with external systems, email sends, billing
351
- events, or any side effect where at-least-once delivery matters.
352
- Requires `OutboxModule` to be wired.
353
- - **`@IntegrationEventsHandler`** — smart default, class-level
354
- composite. When the outbox registrar is bound, delivery goes
355
- through the outbox (durable). Without it, delivery falls back to
356
- the in-memory dispatcher with `AFTER_COMMIT` + `async: true` +
357
- fresh-transaction semantics. Matches Spring Modulith's
358
- `@ApplicationModuleListener` contract — "the thing you reach for by
359
- default when wiring cross-module listeners, so you do not have to
360
- revisit every call site when persistence comes online".
361
-
362
- ### Delivery guarantees at a glance
363
-
364
- | Decorator | Persisted? | Retry on failure? | Survives process restart? | Transaction | Typical use case |
365
- | --- | --- | --- | --- | --- | --- |
366
- | `@TransactionalEventsHandler` | No — in-memory only | No | No | Joins the publishing transaction's lifecycle (fires at configured phase) | Cache invalidation, metrics, in-process enrichment |
367
- | `@OutboxEventsHandler` | Yes — `event_publication` row per listener | Yes — via operator-triggered resubmit | Yes — `republishOnStartup` replays | `REQUIRES_NEW` per invocation (default) | External API calls, emails, billing events, cross-module integration where loss is unacceptable |
368
- | `@IntegrationEventsHandler` | Yes if outbox registrar bound, No otherwise | Yes if outbox bound | Yes if outbox bound | `REQUIRES_NEW` (outbox) or `AFTER_COMMIT + async: true` inside a fresh tx (fallback) | Default choice for cross-module handlers — upgrades gracefully when the outbox comes online |
369
-
370
- How `@IntegrationEventsHandler` routes depends on module wiring, not
371
- on call-site configuration: write one decorator, and the same handler
372
- runs via the in-memory path during early development and via the
373
- durable outbox once the team is ready to stand up the worker process.
374
- `IntegrationEventsHandlerScanner` decides at bootstrap based on
375
- whether the `OUTBOX_LISTENER_REGISTRAR` provider is bound — so the
376
- handler fires exactly once.
119
+ `CqrsTransactionalModule.forRoot()` wraps handlers at bootstrap:
377
120
 
378
- ```ts
379
- @Injectable()
380
- @IntegrationEventsHandler(OrderPlacedEvent)
381
- export class InventoryReservationHandler
382
- implements IIntegrationEventHandler<OrderPlacedEvent>
383
- {
384
- async handle(event: OrderPlacedEvent): Promise<void> {
385
- // with outbox wired: runs from the worker, retried on failure.
386
- // without outbox: runs in-memory after commit, fire-and-forget.
387
- }
388
- }
389
- ```
121
+ - **Command handlers** carrying `@Transactional()` (method- or
122
+ class-level). Set `defaultCommandOptions` to wrap them all.
123
+ - **Query handlers** — wrapped read-only by default
124
+ (`defaultQueryOptions: { readOnly: true }`). Pass `undefined` to opt
125
+ out. Note that `readOnly` is enforced by the database only on
126
+ Postgres-family dialects.
127
+ - **Event handlers** only when they carry `@Transactional()`. There is
128
+ no kind-level default, because event handlers are often out-of-band
129
+ side effects where a transaction is the wrong thing.
390
130
 
391
- Supply a stable `id` when the class name might change:
131
+ Async configuration works the same way, with one wrinkle:
392
132
 
393
133
  ```ts
394
- @IntegrationEventsHandler({
395
- events: [OrderPlacedEvent],
396
- id: 'Inventory.stable-id',
397
- })
134
+ CqrsTransactionalModule.forRootAsync({
135
+ imports: [ConfigModule],
136
+ inject: [ConfigService],
137
+ useFactory: (cfg: ConfigService) => ({ wrapQueryHandlers: cfg.get('WRAP') !== 'false' }),
138
+ // Structural, so it stays outside the factory: it decides whether the
139
+ // EventPublisher override provider exists at all, and NestJS needs
140
+ // provider tokens before any factory has run.
141
+ useTransactionalEventPublisher: true,
142
+ });
398
143
  ```
399
144
 
400
- The listener id format is `${baseId}#${EventName}` where baseId
401
- defaults to the class name — so class renames invalidate stored
402
- publications unless `options.id` is set.
403
-
404
- ## Worked examples
405
-
406
- - [`basic-cqrs`](../../examples/basic-cqrs) — Command + Query (auto-readonly) + AFTER_COMMIT `@TransactionalEventsHandler`, no DB.
407
- - [`multi-datasource-cqrs`](../../examples/multi-datasource-cqrs) — `@Transactional({ dataSource })` per handler with per-DS hook attachment.
408
- - [`saga-pattern`](../../examples/saga-pattern), [`audit-logging`](../../examples/audit-logging) — `@TransactionalEventsHandler` + `@OutboxEventsHandler` against the same event class.
409
- - [`e-commerce-orders`](../../examples/e-commerce-orders) — full CQRS + REST controller + outbox-driven saga + multi-DS.
145
+ ## Choosing a handler decorator
410
146
 
411
- Full catalogue: [examples/README.md](../../examples/README.md).
147
+ | | Persisted | Retried | Survives restart |
148
+ | --- | --- | --- | --- |
149
+ | `@TransactionalEventsHandler` | no | no | no |
150
+ | `@OutboxEventsHandler` *(outbox package)* | yes | yes | yes |
151
+ | `@IntegrationEventsHandler` | if the outbox is wired | if wired | if wired |
412
152
 
413
- ## Handler scopes
153
+ Use `@TransactionalEventsHandler` for in-process work that is fine to
154
+ lose on a crash — cache invalidation, metrics. Use
155
+ `@OutboxEventsHandler` when at-least-once delivery matters: external
156
+ API calls, emails, billing.
414
157
 
415
- Works with handlers of any `@nestjs/cqrs` scope
416
- `Scope.DEFAULT` (singleton), `Scope.REQUEST`, and `Scope.TRANSIENT`
417
- since [ADR-020](../../docs/adr/020-prototype-level-cqrs-wrapping.md):
418
- the wrap is applied to the handler class **prototype**, which
419
- intercepts `@nestjs/cqrs`'s late-bound `instance.execute(query)` lookup
420
- regardless of how the instance is resolved.
158
+ `@IntegrationEventsHandler` is the one to reach for by default in
159
+ cross-module code. It routes through the outbox when
160
+ `OUTBOX_LISTENER_REGISTRAR` is bound and falls back to in-memory
161
+ delivery when it is not decided at bootstrap by module wiring, not at
162
+ the call site. The same handler therefore runs in-memory during early
163
+ development and durably once a worker exists, without touching the
164
+ handler. It mirrors Spring Modulith's `@ApplicationModuleListener`.
421
165
 
422
- A common request-scoped pattern uses `@nestjs/cqrs`'s own `AsyncContext`
423
- mechanism to carry per-request data (user, geo, A/B flags, ...) into
424
- the handler via the standard `REQUEST` token:
166
+ To turn on durable delivery, bind both structural ports:
425
167
 
426
168
  ```ts
427
- import { Inject, Scope } from '@nestjs/common';
428
- import { REQUEST } from '@nestjs/core';
429
- import { AsyncContext, QueryHandler, IQueryHandler } from '@nestjs/cqrs';
430
-
431
- @QueryHandler(ListUserContributionsQuery, { scope: Scope.REQUEST })
432
- export class ListUserContributionsQueryHandler
433
- implements IQueryHandler<ListUserContributionsQuery> {
434
- constructor(@Inject(REQUEST) private readonly ctx: AsyncContext) {}
435
-
436
- async execute(query: ListUserContributionsQuery) {
437
- // this.ctx carries the per-request data, the wrap opens a transaction
438
- }
439
- }
169
+ providers: [
170
+ { provide: OUTBOX_PUBLICATION_SCHEDULER, useExisting: OutboxEventPublisher },
171
+ { provide: OUTBOX_LISTENER_REGISTRAR, useExisting: OutboxListenerRegistry },
172
+ ];
440
173
  ```
441
174
 
442
- Multiple dispatches that should share one handler instance per HTTP
443
- request need to share one `AsyncContext` — either pass it as the second
444
- argument (`queryBus.execute(query, ctx)`) or attach it to each query
445
- via `AsyncContext.merge(source, query)`. Without sharing, each dispatch
446
- gets a new `AsyncContext` (and a new instance) that is `@nestjs/cqrs`
447
- behaviour and unrelated to the transaction wrap.
175
+ A rollback then undoes all of it: no in-memory handler fires, no
176
+ publication row persists, nothing downstream runs.
177
+
178
+ Listener ids are `${baseId}#${EventName}`, with `baseId` defaulting to
179
+ the class name — so pass an explicit `id` if the class may be renamed,
180
+ or stored publications will be orphaned.
448
181
 
449
182
  ## Limitations
450
183
 
451
- - Direct `eventBus.publish(...)` calls (outside of an aggregate) do NOT
452
- go through the transactional dispatcher — only `AggregateRoot.commit()`
453
- -emitted events via `mergeObjectContext` / `mergeClassContext`. If you
454
- need phase-aware handlers on bus-published events, publish them from
455
- an aggregate instead.
456
- - Arrow-function `execute = async (q) => {...}` /
457
- `handle = async (e) => {...}` defined as instance fields are not
458
- wrapped. The wrap point is the class prototype, and instance arrow
459
- fields shadow the prototype. Use regular method syntax
460
- (`async execute(q) { ... }`) so the method lives on the prototype.
461
- - `@nestjs/cqrs`'s handler-metadata constants are read via hardcoded
462
- string literals (`__commandHandler__`, etc.) because `@nestjs/cqrs`
463
- does not re-export them. See `handler-wrapper.ts`
464
- [DD-002](../../docs/dd/002-no-fork-nestjs-cqrs.md) documents this
465
- coupling.
466
-
467
- ## Status
468
-
469
- Alpha. Public API may change between 0.x releases.
184
+ - **`eventBus.publish(...)` bypasses the dispatcher.** Only events
185
+ emitted by an aggregate through `mergeObjectContext` /
186
+ `mergeClassContext` and `commit()` become phase-aware.
187
+ - **Arrow-function class fields are not wrapped.** The wrap point is the
188
+ prototype, and `execute = async (q) => {}` shadows it. Use method
189
+ syntax.
190
+ - **`@nestjs/cqrs@11` only**, deliberately, while the other peers accept
191
+ `^10 || ^11`. The wrapping mechanism would work on v10, but
192
+ `AsyncContext` which request-scoped handler support depends on —
193
+ does not exist there, and advertising `^10` would promise a documented
194
+ feature that cannot work.
195
+
196
+ Handlers of any scope are supported, including `Scope.REQUEST` and
197
+ `Scope.TRANSIENT`, because the wrap is applied to the prototype
198
+ ([ADR-020](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/020-prototype-level-cqrs-wrapping.md)).
199
+
200
+ ## Documentation
201
+
202
+ - [Getting started and full docs](https://github.com/igorgolovanov/nestjs-transactional#readme)
203
+ - [Transactional events and Spring semantics (ADR-002)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/002-transactional-events-spring-semantics.md)
204
+ - [Handler API design (ADR-014)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/014-handler-api-redesign.md)
205
+ - [Why `@nestjs/cqrs` is not forked (DD-002)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/dd/002-no-fork-nestjs-cqrs.md)
206
+ - Runnable examples:
207
+ [`basic-cqrs`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/basic-cqrs),
208
+ [`multi-datasource-cqrs`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/multi-datasource-cqrs),
209
+ [`saga-pattern`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/saga-pattern),
210
+ [`e-commerce-orders`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/e-commerce-orders)
211
+
212
+ ## License
213
+
214
+ MIT
@@ -27,7 +27,7 @@ export interface IntegrationEventsHandlerOptions {
27
27
  readonly id?: string;
28
28
  /**
29
29
  * dataSource the in-memory dispatcher fallback path attaches phase
30
- * hooks to (Phase 14.3.1). Only consulted when the outbox is NOT
30
+ * hooks to. Only consulted when the outbox is NOT
31
31
  * wired (no `OUTBOX_LISTENER_REGISTRAR` binding) — the outbox path
32
32
  * auto-resolves the dataSource by walking per-DS event-type
33
33
  * registries.
@@ -102,7 +102,7 @@ export interface IntegrationEventsHandlerMetadata {
102
102
  * (a DI concept), and (b) "Integration events" is the established
103
103
  * DDD/microservices term for cross-module/cross-service event flow.
104
104
  *
105
- * **Multi-dataSource setups (Phase 14.3.1).** When the outbox path
105
+ * **Multi-dataSource setups.** When the outbox path
106
106
  * is wired, `OutboxModule.forRoot` auto-binds
107
107
  * `OUTBOX_LISTENER_REGISTRAR` to a smart
108
108
  * `MultiDsOutboxListenerRegistrar` that walks per-dataSource