@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.
- package/LICENSE +21 -0
- package/README.md +431 -0
- package/dist/decorators/integration-events-handler.decorator.d.ts +125 -0
- package/dist/decorators/integration-events-handler.decorator.js +57 -0
- package/dist/decorators/integration-events-handler.decorator.js.map +1 -0
- package/dist/decorators/transactional-events-handler.decorator.d.ts +138 -0
- package/dist/decorators/transactional-events-handler.decorator.js +65 -0
- package/dist/decorators/transactional-events-handler.decorator.js.map +1 -0
- package/dist/event-dispatcher/event-dispatcher.d.ts +100 -0
- package/dist/event-dispatcher/event-dispatcher.js +229 -0
- package/dist/event-dispatcher/event-dispatcher.js.map +1 -0
- package/dist/event-publisher/hybrid-event-publisher.d.ts +76 -0
- package/dist/event-publisher/hybrid-event-publisher.js +86 -0
- package/dist/event-publisher/hybrid-event-publisher.js.map +1 -0
- package/dist/event-publisher/transactional-event-publisher-adapter.d.ts +32 -0
- package/dist/event-publisher/transactional-event-publisher-adapter.js +72 -0
- package/dist/event-publisher/transactional-event-publisher-adapter.js.map +1 -0
- package/dist/event-publisher/transactional-event-publisher.d.ts +33 -0
- package/dist/event-publisher/transactional-event-publisher.js +58 -0
- package/dist/event-publisher/transactional-event-publisher.js.map +1 -0
- package/dist/handlers/bootstrap.d.ts +18 -0
- package/dist/handlers/bootstrap.js +39 -0
- package/dist/handlers/bootstrap.js.map +1 -0
- package/dist/handlers/handler-wrapper.d.ts +77 -0
- package/dist/handlers/handler-wrapper.js +183 -0
- package/dist/handlers/handler-wrapper.js.map +1 -0
- package/dist/handlers/integration-events-handler-scanner.d.ts +37 -0
- package/dist/handlers/integration-events-handler-scanner.js +144 -0
- package/dist/handlers/integration-events-handler-scanner.js.map +1 -0
- package/dist/handlers/listener-scanner.d.ts +33 -0
- package/dist/handlers/listener-scanner.js +86 -0
- package/dist/handlers/listener-scanner.js.map +1 -0
- package/dist/handlers/outbox-listener-registrar.d.ts +49 -0
- package/dist/handlers/outbox-listener-registrar.js +17 -0
- package/dist/handlers/outbox-listener-registrar.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/integration-event-handler.interface.d.ts +35 -0
- package/dist/interfaces/integration-event-handler.interface.js +3 -0
- package/dist/interfaces/integration-event-handler.interface.js.map +1 -0
- package/dist/interfaces/transactional-event-handler.interface.d.ts +31 -0
- package/dist/interfaces/transactional-event-handler.interface.js +3 -0
- package/dist/interfaces/transactional-event-handler.interface.js.map +1 -0
- package/dist/module/cqrs-transactional.module.d.ts +92 -0
- package/dist/module/cqrs-transactional.module.js +137 -0
- package/dist/module/cqrs-transactional.module.js.map +1 -0
- package/dist/types/transactional-listener.types.d.ts +21 -0
- package/dist/types/transactional-listener.types.js +25 -0
- package/dist/types/transactional-listener.types.js.map +1 -0
- 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
|
+
[](https://www.npmjs.com/package/@nestjs-transactional/cqrs)
|
|
4
|
+
[](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"}
|