@nestjs-transactional/cqrs 1.0.0-alpha.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 (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +431 -0
  3. package/dist/decorators/integration-events-handler.decorator.d.ts +125 -0
  4. package/dist/decorators/integration-events-handler.decorator.js +57 -0
  5. package/dist/decorators/integration-events-handler.decorator.js.map +1 -0
  6. package/dist/decorators/transactional-events-handler.decorator.d.ts +138 -0
  7. package/dist/decorators/transactional-events-handler.decorator.js +65 -0
  8. package/dist/decorators/transactional-events-handler.decorator.js.map +1 -0
  9. package/dist/event-dispatcher/event-dispatcher.d.ts +100 -0
  10. package/dist/event-dispatcher/event-dispatcher.js +229 -0
  11. package/dist/event-dispatcher/event-dispatcher.js.map +1 -0
  12. package/dist/event-publisher/hybrid-event-publisher.d.ts +76 -0
  13. package/dist/event-publisher/hybrid-event-publisher.js +86 -0
  14. package/dist/event-publisher/hybrid-event-publisher.js.map +1 -0
  15. package/dist/event-publisher/transactional-event-publisher-adapter.d.ts +32 -0
  16. package/dist/event-publisher/transactional-event-publisher-adapter.js +72 -0
  17. package/dist/event-publisher/transactional-event-publisher-adapter.js.map +1 -0
  18. package/dist/event-publisher/transactional-event-publisher.d.ts +33 -0
  19. package/dist/event-publisher/transactional-event-publisher.js +58 -0
  20. package/dist/event-publisher/transactional-event-publisher.js.map +1 -0
  21. package/dist/handlers/bootstrap.d.ts +18 -0
  22. package/dist/handlers/bootstrap.js +39 -0
  23. package/dist/handlers/bootstrap.js.map +1 -0
  24. package/dist/handlers/handler-wrapper.d.ts +77 -0
  25. package/dist/handlers/handler-wrapper.js +183 -0
  26. package/dist/handlers/handler-wrapper.js.map +1 -0
  27. package/dist/handlers/integration-events-handler-scanner.d.ts +37 -0
  28. package/dist/handlers/integration-events-handler-scanner.js +144 -0
  29. package/dist/handlers/integration-events-handler-scanner.js.map +1 -0
  30. package/dist/handlers/listener-scanner.d.ts +33 -0
  31. package/dist/handlers/listener-scanner.js +86 -0
  32. package/dist/handlers/listener-scanner.js.map +1 -0
  33. package/dist/handlers/outbox-listener-registrar.d.ts +49 -0
  34. package/dist/handlers/outbox-listener-registrar.js +17 -0
  35. package/dist/handlers/outbox-listener-registrar.js.map +1 -0
  36. package/dist/index.d.ts +16 -0
  37. package/dist/index.js +37 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/interfaces/integration-event-handler.interface.d.ts +35 -0
  40. package/dist/interfaces/integration-event-handler.interface.js +3 -0
  41. package/dist/interfaces/integration-event-handler.interface.js.map +1 -0
  42. package/dist/interfaces/transactional-event-handler.interface.d.ts +31 -0
  43. package/dist/interfaces/transactional-event-handler.interface.js +3 -0
  44. package/dist/interfaces/transactional-event-handler.interface.js.map +1 -0
  45. package/dist/module/cqrs-transactional.module.d.ts +92 -0
  46. package/dist/module/cqrs-transactional.module.js +137 -0
  47. package/dist/module/cqrs-transactional.module.js.map +1 -0
  48. package/dist/types/transactional-listener.types.d.ts +21 -0
  49. package/dist/types/transactional-listener.types.js +25 -0
  50. package/dist/types/transactional-listener.types.js.map +1 -0
  51. package/package.json +73 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Igor Golovanov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,431 @@
1
+ # @nestjs-transactional/cqrs
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)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/LICENSE)
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
58
+
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';
64
+
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 {}
81
+ ```
82
+
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';
117
+
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
+ ```
132
+
133
+ ```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
+ @CommandHandler(PlaceOrderCommand)
145
+ export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand, void> {
146
+ constructor(
147
+ private readonly publisher: EventPublisher,
148
+ private readonly repo: OrderRepository,
149
+ ) {}
150
+
151
+ @Transactional()
152
+ async execute(command: PlaceOrderCommand): Promise<void> {
153
+ const order = this.publisher.mergeObjectContext(new Order(command.orderId));
154
+ order.place();
155
+ await this.repo.save(order);
156
+ order.commit(); // events attach as AFTER_COMMIT hooks on the current tx
157
+ }
158
+ }
159
+ ```
160
+
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
+ }
181
+
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
+ ```
197
+
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:
232
+
233
+ ```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
+ @TransactionalEventsHandler({
242
+ events: [OrderPlacedEvent],
243
+ phase: TransactionPhase.BEFORE_COMMIT,
244
+ async: false,
245
+ fallbackExecution: true,
246
+ })
247
+ @IntegrationEventsHandler({
248
+ events: [OrderPlacedEvent],
249
+ id: 'Inventory.stable-id',
250
+ })
251
+ ```
252
+
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';
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
+ ```
323
+
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.
377
+
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
+ ```
390
+
391
+ Supply a stable `id` when the class name might change:
392
+
393
+ ```ts
394
+ @IntegrationEventsHandler({
395
+ events: [OrderPlacedEvent],
396
+ id: 'Inventory.stable-id',
397
+ })
398
+ ```
399
+
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.
410
+
411
+ Full catalogue: [examples/README.md](../../examples/README.md).
412
+
413
+ ## Limitations
414
+
415
+ - Only works with **singleton** handlers. Request-scoped CQRS handlers
416
+ are resolved per-request by `@nestjs/cqrs` via `ModuleRef.resolve(...)`,
417
+ producing a fresh instance our bootstrap wrap has not mutated.
418
+ - Direct `eventBus.publish(...)` calls (outside of an aggregate) do NOT
419
+ go through the transactional dispatcher — only `AggregateRoot.commit()`
420
+ -emitted events via `mergeObjectContext` / `mergeClassContext`. If you
421
+ need phase-aware handlers on bus-published events, publish them from
422
+ an aggregate instead.
423
+ - `@nestjs/cqrs`'s handler-metadata constants are read via hardcoded
424
+ string literals (`__commandHandler__`, etc.) because `@nestjs/cqrs`
425
+ does not re-export them. See `handler-wrapper.ts` —
426
+ [DD-002](../../docs/dd/002-no-fork-nestjs-cqrs.md) documents this
427
+ coupling.
428
+
429
+ ## Status
430
+
431
+ Alpha. Public API may change between 0.x releases.
@@ -0,0 +1,125 @@
1
+ import 'reflect-metadata';
2
+ import { type Type } from '@nestjs/common';
3
+ /**
4
+ * Metadata key under which {@link IntegrationEventsHandlerMetadata} is
5
+ * stored on classes decorated with {@link IntegrationEventsHandler}.
6
+ *
7
+ * Private to the cqrs package — not shared with outbox (the
8
+ * smart scanner routes the handler based on whether a registrar is
9
+ * bound, not by inspecting shared metadata).
10
+ */
11
+ export declare const INTEGRATION_EVENTS_HANDLER_METADATA: unique symbol;
12
+ /**
13
+ * Options accepted by the long form of {@link IntegrationEventsHandler}.
14
+ */
15
+ export interface IntegrationEventsHandlerOptions {
16
+ /** Domain event classes the handler subscribes to. Must be non-empty. */
17
+ readonly events: Type[];
18
+ /**
19
+ * Stable, globally-unique listener id used by the outbox registry to
20
+ * resolve which handler to invoke for a stored publication. When
21
+ * omitted, the scanner derives one from
22
+ * `${ClassName}#${EventName}` per event type — a rename of the class
23
+ * therefore breaks resume of already-stored publications. Supply an
24
+ * explicit id to protect against this. When multiple events are
25
+ * declared, the scanner appends `#${EventName}` to the supplied id.
26
+ */
27
+ readonly id?: string;
28
+ /**
29
+ * dataSource the in-memory dispatcher fallback path attaches phase
30
+ * hooks to (Phase 14.3.1). Only consulted when the outbox is NOT
31
+ * wired (no `OUTBOX_LISTENER_REGISTRAR` binding) — the outbox path
32
+ * auto-resolves the dataSource by walking per-DS event-type
33
+ * registries.
34
+ *
35
+ * Defaults to `'default'`. Multi-dataSource apps using the
36
+ * dispatcher fallback declare it explicitly on a non-default
37
+ * handler.
38
+ */
39
+ readonly dataSource?: string;
40
+ }
41
+ /**
42
+ * Resolved metadata attached to a handler class.
43
+ */
44
+ export interface IntegrationEventsHandlerMetadata {
45
+ readonly eventTypes: Type[];
46
+ readonly id?: string;
47
+ readonly dataSource: string;
48
+ }
49
+ /**
50
+ * Smart-default decorator for cross-module / cross-service integration
51
+ * event handlers. The NestJS-idiomatic equivalent of Spring Modulith's
52
+ * `@ApplicationModuleListener` — see "Naming" below.
53
+ *
54
+ * Behaviour depends on module wiring, decided at bootstrap by
55
+ * `IntegrationEventsHandlerScanner`:
56
+ *
57
+ * 1. **Outbox wired** (the `OUTBOX_LISTENER_REGISTRAR` provider is
58
+ * bound, typically via `OutboxModule`): the handler is registered
59
+ * as a persistent outbox listener with `newTransaction: true`
60
+ * semantics. Delivery is durable, at-least-once, retried on
61
+ * failure, and survives process restarts.
62
+ *
63
+ * 2. **Outbox NOT wired**: the handler is registered in-memory via
64
+ * `TransactionalEventDispatcher` with `phase: AFTER_COMMIT`,
65
+ * `async: true`, and wrapped in a `REQUIRES_NEW` transaction —
66
+ * mirroring the outbox-backed behaviour as closely as in-memory
67
+ * dispatch allows (minus persistence).
68
+ *
69
+ * Either way, consumer code is identical:
70
+ *
71
+ * ```ts
72
+ * @IntegrationEventsHandler(OrderPlacedEvent)
73
+ * export class InventoryReservationHandler
74
+ * implements IIntegrationEventHandler<OrderPlacedEvent>
75
+ * {
76
+ * async handle(event: OrderPlacedEvent): Promise<void> { ... }
77
+ * }
78
+ * ```
79
+ *
80
+ * Two forms:
81
+ *
82
+ * ```ts
83
+ * // Short form:
84
+ * @IntegrationEventsHandler(OrderPlacedEvent, OrderCancelledEvent)
85
+ *
86
+ * // Long form with stable id:
87
+ * @IntegrationEventsHandler({
88
+ * events: [OrderPlacedEvent],
89
+ * id: 'inventory.reservation',
90
+ * })
91
+ * ```
92
+ *
93
+ * Behaviour is opinionated and fixed: AFTER_COMMIT phase, async
94
+ * execution, REQUIRES_NEW transaction. If you need any of those to
95
+ * differ, use {@link TransactionalEventsHandler} with explicit
96
+ * options instead — that decorator exposes the full configuration
97
+ * surface for in-memory event handling.
98
+ *
99
+ * **Naming.** The Spring Modulith decorator with this role is called
100
+ * `@ApplicationModuleListener`. We use `@IntegrationEventsHandler`
101
+ * because (a) "Application Module" overlaps with NestJS's `@Module()`
102
+ * (a DI concept), and (b) "Integration events" is the established
103
+ * DDD/microservices term for cross-module/cross-service event flow.
104
+ *
105
+ * **Multi-dataSource setups (Phase 14.3.1).** When the outbox path
106
+ * is wired, `OutboxModule.forRoot` auto-binds
107
+ * `OUTBOX_LISTENER_REGISTRAR` to a smart
108
+ * `MultiDsOutboxListenerRegistrar` that walks per-dataSource
109
+ * `EventTypeRegistry` instances to find which dataSource owns each
110
+ * handler's events and registers the listener with the matching
111
+ * per-DS registry — automatic, no decorator option required.
112
+ * Handlers subscribing to events across multiple dataSources are
113
+ * rejected at bootstrap (handlers must be dataSource-scoped).
114
+ *
115
+ * @throws {Error} If no event types are supplied.
116
+ */
117
+ export declare function IntegrationEventsHandler(...events: Type[]): ClassDecorator;
118
+ export declare function IntegrationEventsHandler(options: IntegrationEventsHandlerOptions): ClassDecorator;
119
+ /**
120
+ * Read the {@link IntegrationEventsHandlerMetadata} attached to
121
+ * `target` by {@link IntegrationEventsHandler}. Returns `undefined`
122
+ * when the class was not decorated.
123
+ */
124
+ export declare function getIntegrationEventsHandlerMetadata(target: object): IntegrationEventsHandlerMetadata | undefined;
125
+ //# sourceMappingURL=integration-events-handler.decorator.d.ts.map
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INTEGRATION_EVENTS_HANDLER_METADATA = void 0;
4
+ exports.IntegrationEventsHandler = IntegrationEventsHandler;
5
+ exports.getIntegrationEventsHandlerMetadata = getIntegrationEventsHandlerMetadata;
6
+ require("reflect-metadata");
7
+ const core_1 = require("@nestjs-transactional/core");
8
+ /**
9
+ * Metadata key under which {@link IntegrationEventsHandlerMetadata} is
10
+ * stored on classes decorated with {@link IntegrationEventsHandler}.
11
+ *
12
+ * Private to the cqrs package — not shared with outbox (the
13
+ * smart scanner routes the handler based on whether a registrar is
14
+ * bound, not by inspecting shared metadata).
15
+ */
16
+ exports.INTEGRATION_EVENTS_HANDLER_METADATA = Symbol('INTEGRATION_EVENTS_HANDLER_METADATA');
17
+ function IntegrationEventsHandler(...args) {
18
+ const metadata = resolveMetadata(args);
19
+ if (metadata.eventTypes.length === 0) {
20
+ throw new Error('@IntegrationEventsHandler requires at least one event type. ' +
21
+ 'Pass class constructors as rest arguments or via the `events` option.');
22
+ }
23
+ return (target) => {
24
+ Reflect.defineMetadata(exports.INTEGRATION_EVENTS_HANDLER_METADATA, metadata, target);
25
+ };
26
+ }
27
+ function resolveMetadata(args) {
28
+ if (args.length === 1 && isOptionsObject(args[0])) {
29
+ const options = args[0];
30
+ return {
31
+ eventTypes: [...options.events],
32
+ id: options.id,
33
+ dataSource: options.dataSource ?? core_1.DEFAULT_DATA_SOURCE_NAME,
34
+ };
35
+ }
36
+ return {
37
+ eventTypes: args,
38
+ dataSource: core_1.DEFAULT_DATA_SOURCE_NAME,
39
+ };
40
+ }
41
+ function isOptionsObject(candidate) {
42
+ return (candidate !== null &&
43
+ typeof candidate === 'object' &&
44
+ !Array.isArray(candidate) &&
45
+ typeof candidate !== 'function' &&
46
+ 'events' in candidate);
47
+ }
48
+ /**
49
+ * Read the {@link IntegrationEventsHandlerMetadata} attached to
50
+ * `target` by {@link IntegrationEventsHandler}. Returns `undefined`
51
+ * when the class was not decorated.
52
+ */
53
+ function getIntegrationEventsHandlerMetadata(target) {
54
+ const value = Reflect.getMetadata(exports.INTEGRATION_EVENTS_HANDLER_METADATA, target);
55
+ return value;
56
+ }
57
+ //# sourceMappingURL=integration-events-handler.decorator.js.map
@@ -0,0 +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"}