@nestjs-transactional/cqrs 1.0.0-alpha.5 → 1.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.
package/README.md CHANGED
@@ -1,469 +1,200 @@
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).
8
+
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.
58
14
 
59
15
  ```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';
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
+ }
23
+ ```
24
+
25
+ Command and query handlers get transactions by decoration, and
26
+ `@nestjs/cqrs` is used as-is — not forked, not patched.
27
+
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.
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ pnpm add @nestjs-transactional/cqrs @nestjs-transactional/core @nestjs/cqrs
38
+ ```
64
39
 
40
+ ## Quick start
41
+
42
+ ```ts
65
43
  @Module({
66
44
  imports: [
67
45
  TransactionalModule.forRoot({ isGlobal: true }),
68
46
  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
- }),
47
+ CqrsTransactionalModule.forRoot(),
78
48
  ],
79
49
  })
80
50
  export class AppModule {}
81
51
  ```
82
52
 
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.
88
-
89
- ## Full example
90
-
91
- An order placement flow, end-to-end:
92
-
93
- ```ts
94
- // aggregate.ts
95
- import { AggregateRoot } from '@nestjs/cqrs';
96
-
97
- export class OrderPlacedEvent {
98
- constructor(public readonly orderId: string) {}
99
- }
100
-
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
- ```
110
-
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';
53
+ > **Do not import `CqrsModule` as well.** This module imports it
54
+ > internally and overrides the `EventPublisher` token. A second import
55
+ > in your app shadows that override, and aggregate events silently stop
56
+ > reaching the dispatcher no error, just handlers that never fire.
117
57
 
118
- @Injectable()
119
- export class OrderRepository {
120
- constructor(
121
- @InjectRepository(OrderRow) private readonly rows: Repository<OrderRow>,
122
- ) {}
123
-
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
- ```
58
+ Then a command handler, transactional by decoration:
132
59
 
133
60
  ```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
- }
143
-
144
61
  @CommandHandler(PlaceOrderCommand)
145
- export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand, void> {
62
+ export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
146
63
  constructor(
147
64
  private readonly publisher: EventPublisher,
148
- private readonly repo: OrderRepository,
65
+ private readonly orders: OrderRepository,
149
66
  ) {}
150
67
 
151
68
  @Transactional()
152
- async execute(command: PlaceOrderCommand): Promise<void> {
69
+ async execute(command: PlaceOrderCommand) {
153
70
  const order = this.publisher.mergeObjectContext(new Order(command.orderId));
154
71
  order.place();
155
- await this.repo.save(order);
156
- order.commit(); // events attach as AFTER_COMMIT hooks on the current tx
72
+ await this.orders.save(order);
73
+ order.commit(); // events become hooks on this transaction
157
74
  }
158
75
  }
159
76
  ```
160
77
 
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
- }
78
+ `order.commit()` does not dispatch immediately. Each event attaches to
79
+ the current transaction at its handler's phase, so the commit decides
80
+ what runs.
181
81
 
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
- ```
82
+ ## Event phases
197
83
 
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:
84
+ | Phase | Fires | If the handler throws |
85
+ | --- | --- | --- |
86
+ | `BEFORE_COMMIT` | before COMMIT is issued | the transaction rolls back |
87
+ | `AFTER_COMMIT` *(default)* | after COMMIT succeeds | logged and swallowed |
88
+ | `AFTER_ROLLBACK` | after ROLLBACK, with the causing error | logged and swallowed |
89
+ | `AFTER_COMPLETION` | on either outcome | logged and swallowed |
232
90
 
233
91
  ```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
92
  @TransactionalEventsHandler({
242
93
  events: [OrderPlacedEvent],
243
- phase: TransactionPhase.BEFORE_COMMIT,
244
- async: false,
245
- fallbackExecution: true,
246
- })
247
- @IntegrationEventsHandler({
248
- events: [OrderPlacedEvent],
249
- id: 'Inventory.stable-id',
94
+ phase: TransactionPhase.AFTER_ROLLBACK,
250
95
  })
251
96
  ```
252
97
 
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:
98
+ Two flags worth knowing: `fallbackExecution: true` makes a handler fire
99
+ even when the event is published outside any transaction (otherwise such
100
+ events are dropped with a warning), and `async: true` fires it through
101
+ `queueMicrotask` so its errors can never reach the rollback path.
292
102
 
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';
305
-
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
- ```
103
+ ## What gets wrapped
323
104
 
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.
105
+ `CqrsTransactionalModule.forRoot()` wraps handlers at bootstrap:
377
106
 
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
- ```
107
+ - **Command handlers** carrying `@Transactional()` (method- or
108
+ class-level). Set `defaultCommandOptions` to wrap them all.
109
+ - **Query handlers** — wrapped read-only by default
110
+ (`defaultQueryOptions: { readOnly: true }`). Pass `undefined` to opt
111
+ out. Note that `readOnly` is enforced by the database only on
112
+ Postgres-family dialects.
113
+ - **Event handlers** only when they carry `@Transactional()`. There is
114
+ no kind-level default, because event handlers are often out-of-band
115
+ side effects where a transaction is the wrong thing.
390
116
 
391
- Supply a stable `id` when the class name might change:
117
+ Async configuration works the same way, with one wrinkle:
392
118
 
393
119
  ```ts
394
- @IntegrationEventsHandler({
395
- events: [OrderPlacedEvent],
396
- id: 'Inventory.stable-id',
397
- })
120
+ CqrsTransactionalModule.forRootAsync({
121
+ imports: [ConfigModule],
122
+ inject: [ConfigService],
123
+ useFactory: (cfg: ConfigService) => ({ wrapQueryHandlers: cfg.get('WRAP') !== 'false' }),
124
+ // Structural, so it stays outside the factory: it decides whether the
125
+ // EventPublisher override provider exists at all, and NestJS needs
126
+ // provider tokens before any factory has run.
127
+ useTransactionalEventPublisher: true,
128
+ });
398
129
  ```
399
130
 
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
131
+ ## Choosing a handler decorator
405
132
 
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.
133
+ | | Persisted | Retried | Survives restart |
134
+ | --- | --- | --- | --- |
135
+ | `@TransactionalEventsHandler` | no | no | no |
136
+ | `@OutboxEventsHandler` *(outbox package)* | yes | yes | yes |
137
+ | `@IntegrationEventsHandler` | if the outbox is wired | if wired | if wired |
410
138
 
411
- Full catalogue: [examples/README.md](../../examples/README.md).
139
+ Use `@TransactionalEventsHandler` for in-process work that is fine to
140
+ lose on a crash — cache invalidation, metrics. Use
141
+ `@OutboxEventsHandler` when at-least-once delivery matters: external
142
+ API calls, emails, billing.
412
143
 
413
- ## Handler scopes
144
+ `@IntegrationEventsHandler` is the one to reach for by default in
145
+ cross-module code. It routes through the outbox when
146
+ `OUTBOX_LISTENER_REGISTRAR` is bound and falls back to in-memory
147
+ delivery when it is not — decided at bootstrap by module wiring, not at
148
+ the call site. The same handler therefore runs in-memory during early
149
+ development and durably once a worker exists, without touching the
150
+ handler. It mirrors Spring Modulith's `@ApplicationModuleListener`.
414
151
 
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.
421
-
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:
152
+ To turn on durable delivery, bind both structural ports:
425
153
 
426
154
  ```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
- }
155
+ providers: [
156
+ { provide: OUTBOX_PUBLICATION_SCHEDULER, useExisting: OutboxEventPublisher },
157
+ { provide: OUTBOX_LISTENER_REGISTRAR, useExisting: OutboxListenerRegistry },
158
+ ];
440
159
  ```
441
160
 
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.
161
+ A rollback then undoes all of it: no in-memory handler fires, no
162
+ publication row persists, nothing downstream runs.
163
+
164
+ Listener ids are `${baseId}#${EventName}`, with `baseId` defaulting to
165
+ the class name — so pass an explicit `id` if the class may be renamed,
166
+ or stored publications will be orphaned.
448
167
 
449
168
  ## Limitations
450
169
 
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.
170
+ - **`eventBus.publish(...)` bypasses the dispatcher.** Only events
171
+ emitted by an aggregate through `mergeObjectContext` /
172
+ `mergeClassContext` and `commit()` become phase-aware.
173
+ - **Arrow-function class fields are not wrapped.** The wrap point is the
174
+ prototype, and `execute = async (q) => {}` shadows it. Use method
175
+ syntax.
176
+ - **`@nestjs/cqrs@11` only**, deliberately, while the other peers accept
177
+ `^10 || ^11`. The wrapping mechanism would work on v10, but
178
+ `AsyncContext` which request-scoped handler support depends on —
179
+ does not exist there, and advertising `^10` would promise a documented
180
+ feature that cannot work.
181
+
182
+ Handlers of any scope are supported, including `Scope.REQUEST` and
183
+ `Scope.TRANSIENT`, because the wrap is applied to the prototype
184
+ ([ADR-020](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/020-prototype-level-cqrs-wrapping.md)).
185
+
186
+ ## Documentation
187
+
188
+ - [Getting started and full docs](https://github.com/igorgolovanov/nestjs-transactional#readme)
189
+ - [Transactional events and Spring semantics (ADR-002)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/002-transactional-events-spring-semantics.md)
190
+ - [Handler API design (ADR-014)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/014-handler-api-redesign.md)
191
+ - [Why `@nestjs/cqrs` is not forked (DD-002)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/dd/002-no-fork-nestjs-cqrs.md)
192
+ - Runnable examples:
193
+ [`basic-cqrs`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/basic-cqrs),
194
+ [`multi-datasource-cqrs`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/multi-datasource-cqrs),
195
+ [`saga-pattern`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/saga-pattern),
196
+ [`e-commerce-orders`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/e-commerce-orders)
197
+
198
+ ## License
199
+
200
+ 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
@@ -1 +1 @@
1
- {"version":3,"file":"integration-events-handler.decorator.js","sourceRoot":"","sources":["../../src/decorators/integration-events-handler.decorator.ts"],"names":[],"mappings":";;;AAgIA,4DAeC;AAqCD,kFAKC;AAzLD,4BAA0B;AAG1B,qDAAsE;AAEtE;;;;;;;GAOG;AACU,QAAA,mCAAmC,GAAG,MAAM,CACvD,qCAAqC,CACtC,CAAC;AAiHF,SAAgB,wBAAwB,CACtC,GAAG,IAAgD;IAEnD,MAAM,QAAQ,GAAqC,eAAe,CAAC,IAAI,CAAC,CAAC;IAEzE,IAAI,QAAQ,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CACb,8DAA8D;YAC5D,uEAAuE,CAC1E,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,MAAc,EAAQ,EAAE;QAC9B,OAAO,CAAC,cAAc,CAAC,2CAAmC,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAChF,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CACtB,IAAgD;IAEhD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAClD,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,OAAO;YACL,UAAU,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;YAC/B,EAAE,EAAE,OAAO,CAAC,EAAE;YACd,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,+BAAwB;SAC3D,CAAC;IACJ,CAAC;IAED,OAAO;QACL,UAAU,EAAE,IAAc;QAC1B,UAAU,EAAE,+BAAwB;KACrC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CACtB,SAAkB;IAElB,OAAO,CACL,SAAS,KAAK,IAAI;QAClB,OAAO,SAAS,KAAK,QAAQ;QAC7B,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC;QACzB,OAAO,SAAS,KAAK,UAAU;QAC/B,QAAQ,IAAI,SAAS,CACtB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAgB,mCAAmC,CACjD,MAAc;IAEd,MAAM,KAAK,GAAY,OAAO,CAAC,WAAW,CAAC,2CAAmC,EAAE,MAAM,CAAC,CAAC;IACxF,OAAO,KAAqD,CAAC;AAC/D,CAAC"}
1
+ {"version":3,"file":"integration-events-handler.decorator.js","sourceRoot":"","sources":["../../src/decorators/integration-events-handler.decorator.ts"],"names":[],"mappings":";;;AA4HA,4DAeC;AAmCD,kFAKC;AAnLD,4BAA0B;AAG1B,qDAAsE;AAEtE;;;;;;;GAOG;AACU,QAAA,mCAAmC,GAAG,MAAM,CAAC,qCAAqC,CAAC,CAAC;AA+GjG,SAAgB,wBAAwB,CACtC,GAAG,IAAgD;IAEnD,MAAM,QAAQ,GAAqC,eAAe,CAAC,IAAI,CAAC,CAAC;IAEzE,IAAI,QAAQ,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CACb,8DAA8D;YAC5D,uEAAuE,CAC1E,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,MAAc,EAAQ,EAAE;QAC9B,OAAO,CAAC,cAAc,CAAC,2CAAmC,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAChF,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CACtB,IAAgD;IAEhD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAClD,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,OAAO;YACL,UAAU,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;YAC/B,EAAE,EAAE,OAAO,CAAC,EAAE;YACd,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,+BAAwB;SAC3D,CAAC;IACJ,CAAC;IAED,OAAO;QACL,UAAU,EAAE,IAAc;QAC1B,UAAU,EAAE,+BAAwB;KACrC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,SAAkB;IACzC,OAAO,CACL,SAAS,KAAK,IAAI;QAClB,OAAO,SAAS,KAAK,QAAQ;QAC7B,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC;QACzB,OAAO,SAAS,KAAK,UAAU;QAC/B,QAAQ,IAAI,SAAS,CACtB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAgB,mCAAmC,CACjD,MAAc;IAEd,MAAM,KAAK,GAAY,OAAO,CAAC,WAAW,CAAC,2CAAmC,EAAE,MAAM,CAAC,CAAC;IACxF,OAAO,KAAqD,CAAC;AAC/D,CAAC"}