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