@nestjs-transactional/cqrs 1.0.0-alpha.5 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -410
- package/dist/decorators/integration-events-handler.decorator.d.ts +2 -2
- package/dist/decorators/integration-events-handler.decorator.js.map +1 -1
- package/dist/decorators/transactional-events-handler.decorator.d.ts +3 -3
- package/dist/decorators/transactional-events-handler.decorator.js.map +1 -1
- package/dist/event-dispatcher/event-dispatcher.d.ts +3 -3
- package/dist/event-dispatcher/event-dispatcher.js +3 -3
- package/dist/event-dispatcher/event-dispatcher.js.map +1 -1
- package/dist/event-publisher/hybrid-event-publisher.d.ts +1 -1
- package/dist/event-publisher/hybrid-event-publisher.js +1 -1
- package/dist/event-publisher/transactional-event-publisher-adapter.d.ts +1 -2
- package/dist/event-publisher/transactional-event-publisher-adapter.js.map +1 -1
- package/dist/handlers/integration-events-handler-scanner.js.map +1 -1
- package/dist/handlers/outbox-listener-registrar.d.ts +3 -3
- package/dist/handlers/outbox-listener-registrar.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/module/cqrs-transactional.module.d.ts +65 -7
- package/dist/module/cqrs-transactional.module.js +104 -49
- package/dist/module/cqrs-transactional.module.js.map +1 -1
- package/package.json +15 -7
package/README.md
CHANGED
|
@@ -1,469 +1,200 @@
|
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- **`@TransactionalEventsHandler(...events)`** — class-level event
|
|
15
|
-
handler decorator with Spring-compatible phases: `BEFORE_COMMIT`,
|
|
16
|
-
`AFTER_COMMIT` (default), `AFTER_ROLLBACK`, `AFTER_COMPLETION`. The
|
|
17
|
-
decorated class implements `ITransactionalEventHandler<T>` and
|
|
18
|
-
exposes a single `handle(event)` method. Matches the ergonomics of
|
|
19
|
-
`@nestjs/cqrs`'s own `@EventsHandler` (see ADR-014).
|
|
20
|
-
- **`@IntegrationEventsHandler(...events)`** — class-level smart
|
|
21
|
-
default for cross-module handlers. Delivers via the outbox when the
|
|
22
|
-
`OUTBOX_LISTENER_REGISTRAR` structural port is bound (durable,
|
|
23
|
-
retried, resumable), falls back to in-memory `AFTER_COMMIT` + `async:
|
|
24
|
-
true` dispatch otherwise. Matches Spring Modulith's
|
|
25
|
-
`@ApplicationModuleListener` contract.
|
|
26
|
-
- **`TransactionalEventPublisher` + `TransactionalEventPublisherAdapter`** —
|
|
27
|
-
drop-in replacement for `@nestjs/cqrs`'s `EventPublisher`.
|
|
28
|
-
`AggregateRoot.commit()` routes events through the transactional
|
|
29
|
-
dispatcher, so `AFTER_COMMIT` handlers only fire once the
|
|
30
|
-
transaction actually commits — no more "event published, then
|
|
31
|
-
transaction rolled back" race.
|
|
32
|
-
- **`HybridEventPublisher`** — the strategy wired by
|
|
33
|
-
`CqrsTransactionalModule.forRoot()` into the `EventPublisher`
|
|
34
|
-
override. Routes aggregate events through the in-memory dispatcher
|
|
35
|
-
AND, when an outbox scheduler is bound to the
|
|
36
|
-
`OUTBOX_PUBLICATION_SCHEDULER` token, also through
|
|
37
|
-
`@nestjs-transactional/outbox` for durable delivery. Without
|
|
38
|
-
the outbox binding, behaves identically to
|
|
39
|
-
`TransactionalEventPublisher`.
|
|
40
|
-
- **`CqrsHandlerWrapper` + `CqrsTransactionalBootstrap`** — bootstrap-time
|
|
41
|
-
wrapping of every `@CommandHandler` / `@QueryHandler` / `@EventsHandler`
|
|
42
|
-
instance that carries `@Transactional()` metadata (method-level or
|
|
43
|
-
class-level), or matches kind-specific defaults (e.g. read-only
|
|
44
|
-
wrapping for queries).
|
|
45
|
-
- **`TransactionalListenerScanner` +
|
|
46
|
-
`IntegrationEventsHandlerScanner`** — `OnModuleInit` scanners that
|
|
47
|
-
auto-register every `@TransactionalEventsHandler` /
|
|
48
|
-
`@IntegrationEventsHandler` class with the appropriate delivery
|
|
49
|
-
path.
|
|
50
|
-
- **`CqrsTransactionalModule.forRoot({...})`** — single entry point that
|
|
51
|
-
wires all of the above.
|
|
52
|
-
|
|
53
|
-
Peer dependencies: `@nestjs-transactional/core`, `@nestjs/cqrs ^11`,
|
|
54
|
-
`@nestjs/common ^10 || ^11`, `@nestjs/core ^10 || ^11`, `rxjs ^7`,
|
|
55
|
-
`reflect-metadata`.
|
|
56
|
-
|
|
57
|
-
## Module configuration
|
|
6
|
+
Transactions and Spring-style event phases for
|
|
7
|
+
[`@nestjs/cqrs`](https://docs.nestjs.com/recipes/cqrs).
|
|
8
|
+
|
|
9
|
+
It solves the race everyone hits with domain events: an aggregate emits
|
|
10
|
+
an event, a handler reacts, and then the transaction rolls back — the
|
|
11
|
+
side effect already happened. Here, event handlers declare *when* they
|
|
12
|
+
run relative to the commit, and `AFTER_COMMIT` means the row really is
|
|
13
|
+
in the database.
|
|
58
14
|
|
|
59
15
|
```ts
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
16
|
+
@Injectable()
|
|
17
|
+
@TransactionalEventsHandler(OrderPlacedEvent) // AFTER_COMMIT by default
|
|
18
|
+
export class NotifyCustomer implements ITransactionalEventHandler<OrderPlacedEvent> {
|
|
19
|
+
async handle(event: OrderPlacedEvent) {
|
|
20
|
+
// The order is committed and visible. Safe to send the email.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Command and query handlers get transactions by decoration, and
|
|
26
|
+
`@nestjs/cqrs` is used as-is — not forked, not patched.
|
|
27
|
+
|
|
28
|
+
Built on
|
|
29
|
+
[`@nestjs-transactional/core`](https://www.npmjs.com/package/@nestjs-transactional/core).
|
|
30
|
+
Pair with
|
|
31
|
+
[`@nestjs-transactional/outbox`](https://www.npmjs.com/package/@nestjs-transactional/outbox)
|
|
32
|
+
when a handler must survive a process crash.
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @nestjs-transactional/cqrs @nestjs-transactional/core @nestjs/cqrs
|
|
38
|
+
```
|
|
64
39
|
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```ts
|
|
65
43
|
@Module({
|
|
66
44
|
imports: [
|
|
67
45
|
TransactionalModule.forRoot({ isGlobal: true }),
|
|
68
46
|
TypeOrmTransactionalModule.forRoot(),
|
|
69
|
-
CqrsTransactionalModule.forRoot(
|
|
70
|
-
// every option has a sensible default — shown here for completeness
|
|
71
|
-
wrapCommandHandlers: true,
|
|
72
|
-
wrapQueryHandlers: true,
|
|
73
|
-
wrapEventHandlers: true,
|
|
74
|
-
defaultQueryOptions: { readOnly: true },
|
|
75
|
-
// defaultCommandOptions: { propagation: PropagationMode.REQUIRED },
|
|
76
|
-
useTransactionalEventPublisher: true,
|
|
77
|
-
}),
|
|
47
|
+
CqrsTransactionalModule.forRoot(),
|
|
78
48
|
],
|
|
79
49
|
})
|
|
80
50
|
export class AppModule {}
|
|
81
51
|
```
|
|
82
52
|
|
|
83
|
-
**
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
override with the original.
|
|
88
|
-
|
|
89
|
-
## Full example
|
|
90
|
-
|
|
91
|
-
An order placement flow, end-to-end:
|
|
92
|
-
|
|
93
|
-
```ts
|
|
94
|
-
// aggregate.ts
|
|
95
|
-
import { AggregateRoot } from '@nestjs/cqrs';
|
|
96
|
-
|
|
97
|
-
export class OrderPlacedEvent {
|
|
98
|
-
constructor(public readonly orderId: string) {}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export class Order extends AggregateRoot {
|
|
102
|
-
constructor(public readonly id: string) {
|
|
103
|
-
super();
|
|
104
|
-
}
|
|
105
|
-
place(): void {
|
|
106
|
-
this.apply(new OrderPlacedEvent(this.id));
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
```ts
|
|
112
|
-
// order.repository.ts
|
|
113
|
-
import { Injectable } from '@nestjs/common';
|
|
114
|
-
import { InjectRepository } from '@nestjs/typeorm';
|
|
115
|
-
import { Repository } from 'typeorm';
|
|
116
|
-
import { OrderRow } from './order.entity';
|
|
53
|
+
> **Do not import `CqrsModule` as well.** This module imports it
|
|
54
|
+
> internally and overrides the `EventPublisher` token. A second import
|
|
55
|
+
> in your app shadows that override, and aggregate events silently stop
|
|
56
|
+
> reaching the dispatcher — no error, just handlers that never fire.
|
|
117
57
|
|
|
118
|
-
|
|
119
|
-
export class OrderRepository {
|
|
120
|
-
constructor(
|
|
121
|
-
@InjectRepository(OrderRow) private readonly rows: Repository<OrderRow>,
|
|
122
|
-
) {}
|
|
123
|
-
|
|
124
|
-
async save(order: { id: string }): Promise<void> {
|
|
125
|
-
// The @InjectRepository instance auto-dispatches through the
|
|
126
|
-
// active @Transactional() scope's EntityManager — no
|
|
127
|
-
// getCurrentEntityManager() boilerplate needed.
|
|
128
|
-
await this.rows.save({ id: order.id });
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
```
|
|
58
|
+
Then a command handler, transactional by decoration:
|
|
132
59
|
|
|
133
60
|
```ts
|
|
134
|
-
// place-order.handler.ts
|
|
135
|
-
import { CommandHandler, EventPublisher, type ICommandHandler } from '@nestjs/cqrs';
|
|
136
|
-
import { Transactional } from '@nestjs-transactional/core';
|
|
137
|
-
import { Order } from './aggregate';
|
|
138
|
-
import { OrderRepository } from './order.repository';
|
|
139
|
-
|
|
140
|
-
export class PlaceOrderCommand {
|
|
141
|
-
constructor(public readonly orderId: string) {}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
61
|
@CommandHandler(PlaceOrderCommand)
|
|
145
|
-
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand
|
|
62
|
+
export class PlaceOrderHandler implements ICommandHandler<PlaceOrderCommand> {
|
|
146
63
|
constructor(
|
|
147
64
|
private readonly publisher: EventPublisher,
|
|
148
|
-
private readonly
|
|
65
|
+
private readonly orders: OrderRepository,
|
|
149
66
|
) {}
|
|
150
67
|
|
|
151
68
|
@Transactional()
|
|
152
|
-
async execute(command: PlaceOrderCommand)
|
|
69
|
+
async execute(command: PlaceOrderCommand) {
|
|
153
70
|
const order = this.publisher.mergeObjectContext(new Order(command.orderId));
|
|
154
71
|
order.place();
|
|
155
|
-
await this.
|
|
156
|
-
order.commit(); // events
|
|
72
|
+
await this.orders.save(order);
|
|
73
|
+
order.commit(); // events become hooks on this transaction
|
|
157
74
|
}
|
|
158
75
|
}
|
|
159
76
|
```
|
|
160
77
|
|
|
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
|
-
}
|
|
78
|
+
`order.commit()` does not dispatch immediately. Each event attaches to
|
|
79
|
+
the current transaction at its handler's phase, so the commit decides
|
|
80
|
+
what runs.
|
|
181
81
|
|
|
182
|
-
|
|
183
|
-
@TransactionalEventsHandler({
|
|
184
|
-
events: [OrderPlacedEvent],
|
|
185
|
-
phase: TransactionPhase.AFTER_ROLLBACK,
|
|
186
|
-
})
|
|
187
|
-
export class OrderRollbackProjection
|
|
188
|
-
implements ITransactionalEventHandler<OrderPlacedEvent>
|
|
189
|
-
{
|
|
190
|
-
handle(event: OrderPlacedEvent, error?: unknown): void {
|
|
191
|
-
// Compensating action; receives the rollback cause as the second
|
|
192
|
-
// argument (added beyond the interface signature — TypeScript
|
|
193
|
-
// permits widening the parameter list on the implementation).
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
```
|
|
82
|
+
## Event phases
|
|
197
83
|
|
|
198
|
-
|
|
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:
|
|
84
|
+
| Phase | Fires | If the handler throws |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| `BEFORE_COMMIT` | before COMMIT is issued | the transaction rolls back |
|
|
87
|
+
| `AFTER_COMMIT` *(default)* | after COMMIT succeeds | logged and swallowed |
|
|
88
|
+
| `AFTER_ROLLBACK` | after ROLLBACK, with the causing error | logged and swallowed |
|
|
89
|
+
| `AFTER_COMPLETION` | on either outcome | logged and swallowed |
|
|
232
90
|
|
|
233
91
|
```ts
|
|
234
|
-
// Short form — rest params. Use when defaults are fine.
|
|
235
|
-
@TransactionalEventsHandler(OrderPlacedEvent, OrderCancelledEvent)
|
|
236
|
-
@OutboxEventsHandler(OrderPlacedEvent)
|
|
237
|
-
@IntegrationEventsHandler(OrderPlacedEvent)
|
|
238
|
-
|
|
239
|
-
// Long form — options object. Use when you need non-default phase,
|
|
240
|
-
// async, fallbackExecution, or a stable listener id.
|
|
241
92
|
@TransactionalEventsHandler({
|
|
242
93
|
events: [OrderPlacedEvent],
|
|
243
|
-
phase: TransactionPhase.
|
|
244
|
-
async: false,
|
|
245
|
-
fallbackExecution: true,
|
|
246
|
-
})
|
|
247
|
-
@IntegrationEventsHandler({
|
|
248
|
-
events: [OrderPlacedEvent],
|
|
249
|
-
id: 'Inventory.stable-id',
|
|
94
|
+
phase: TransactionPhase.AFTER_ROLLBACK,
|
|
250
95
|
})
|
|
251
96
|
```
|
|
252
97
|
|
|
253
|
-
|
|
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:
|
|
98
|
+
Two flags worth knowing: `fallbackExecution: true` makes a handler fire
|
|
99
|
+
even when the event is published outside any transaction (otherwise such
|
|
100
|
+
events are dropped with a warning), and `async: true` fires it through
|
|
101
|
+
`queueMicrotask` so its errors can never reach the rollback path.
|
|
292
102
|
|
|
293
|
-
|
|
294
|
-
import { Module } from '@nestjs/common';
|
|
295
|
-
import {
|
|
296
|
-
OutboxEventPublisher,
|
|
297
|
-
OutboxListenerRegistry,
|
|
298
|
-
OutboxModule,
|
|
299
|
-
} from '@nestjs-transactional/outbox';
|
|
300
|
-
import {
|
|
301
|
-
CqrsTransactionalModule,
|
|
302
|
-
OUTBOX_LISTENER_REGISTRAR,
|
|
303
|
-
OUTBOX_PUBLICATION_SCHEDULER,
|
|
304
|
-
} from '@nestjs-transactional/cqrs';
|
|
305
|
-
|
|
306
|
-
@Module({
|
|
307
|
-
imports: [
|
|
308
|
-
// ...the usual wiring — TransactionalModule, a typeorm adapter,
|
|
309
|
-
// OutboxTypeOrmModule, OutboxModule, CqrsTransactionalModule...
|
|
310
|
-
CqrsTransactionalModule.forRoot(),
|
|
311
|
-
],
|
|
312
|
-
providers: [
|
|
313
|
-
// Routes AggregateRoot.commit() events to the outbox for durable
|
|
314
|
-
// publication.
|
|
315
|
-
{ provide: OUTBOX_PUBLICATION_SCHEDULER, useExisting: OutboxEventPublisher },
|
|
316
|
-
// Routes @IntegrationEventsHandler classes to the outbox registry
|
|
317
|
-
// for durable delivery.
|
|
318
|
-
{ provide: OUTBOX_LISTENER_REGISTRAR, useExisting: OutboxListenerRegistry },
|
|
319
|
-
],
|
|
320
|
-
})
|
|
321
|
-
export class AppModule {}
|
|
322
|
-
```
|
|
103
|
+
## What gets wrapped
|
|
323
104
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
1. Attaches one `AFTER_COMMIT` hook per `@TransactionalEventsHandler`
|
|
327
|
-
class registered for the event — fires after the transaction
|
|
328
|
-
commits, entirely in-memory, no DB rows.
|
|
329
|
-
2. Buffers the event for outbox publication — a single
|
|
330
|
-
`beforeCommit` hook per transaction flushes the whole buffer into
|
|
331
|
-
`event_publication` rows, atomically with the business write.
|
|
332
|
-
3. Once the transaction commits, the outbox processor (running in
|
|
333
|
-
a worker) polls those rows and invokes every
|
|
334
|
-
`@OutboxEventsHandler` / `@IntegrationEventsHandler` class
|
|
335
|
-
registered for the event.
|
|
336
|
-
|
|
337
|
-
Rollback rolls back all three: no in-memory handlers fire, no
|
|
338
|
-
publication rows are persisted, nothing downstream runs. This is the
|
|
339
|
-
core guarantee of the outbox pattern — "event published only if the
|
|
340
|
-
business change landed".
|
|
341
|
-
|
|
342
|
-
## Choosing between handler flavours
|
|
343
|
-
|
|
344
|
-
- **`@TransactionalEventsHandler`** — cheap, in-process, phase-aware,
|
|
345
|
-
non-durable. Use for side effects that are OK to lose on a crash
|
|
346
|
-
between commit and invocation (metrics, cache invalidation,
|
|
347
|
-
enrichment of in-memory state).
|
|
348
|
-
- **`@OutboxEventsHandler`** *(from outbox)* — durable,
|
|
349
|
-
retry-on-failure, resumable-across-restart, delivered by a worker.
|
|
350
|
-
Use for integration with external systems, email sends, billing
|
|
351
|
-
events, or any side effect where at-least-once delivery matters.
|
|
352
|
-
Requires `OutboxModule` to be wired.
|
|
353
|
-
- **`@IntegrationEventsHandler`** — smart default, class-level
|
|
354
|
-
composite. When the outbox registrar is bound, delivery goes
|
|
355
|
-
through the outbox (durable). Without it, delivery falls back to
|
|
356
|
-
the in-memory dispatcher with `AFTER_COMMIT` + `async: true` +
|
|
357
|
-
fresh-transaction semantics. Matches Spring Modulith's
|
|
358
|
-
`@ApplicationModuleListener` contract — "the thing you reach for by
|
|
359
|
-
default when wiring cross-module listeners, so you do not have to
|
|
360
|
-
revisit every call site when persistence comes online".
|
|
361
|
-
|
|
362
|
-
### Delivery guarantees at a glance
|
|
363
|
-
|
|
364
|
-
| Decorator | Persisted? | Retry on failure? | Survives process restart? | Transaction | Typical use case |
|
|
365
|
-
| --- | --- | --- | --- | --- | --- |
|
|
366
|
-
| `@TransactionalEventsHandler` | No — in-memory only | No | No | Joins the publishing transaction's lifecycle (fires at configured phase) | Cache invalidation, metrics, in-process enrichment |
|
|
367
|
-
| `@OutboxEventsHandler` | Yes — `event_publication` row per listener | Yes — via operator-triggered resubmit | Yes — `republishOnStartup` replays | `REQUIRES_NEW` per invocation (default) | External API calls, emails, billing events, cross-module integration where loss is unacceptable |
|
|
368
|
-
| `@IntegrationEventsHandler` | Yes if outbox registrar bound, No otherwise | Yes if outbox bound | Yes if outbox bound | `REQUIRES_NEW` (outbox) or `AFTER_COMMIT + async: true` inside a fresh tx (fallback) | Default choice for cross-module handlers — upgrades gracefully when the outbox comes online |
|
|
369
|
-
|
|
370
|
-
How `@IntegrationEventsHandler` routes depends on module wiring, not
|
|
371
|
-
on call-site configuration: write one decorator, and the same handler
|
|
372
|
-
runs via the in-memory path during early development and via the
|
|
373
|
-
durable outbox once the team is ready to stand up the worker process.
|
|
374
|
-
`IntegrationEventsHandlerScanner` decides at bootstrap based on
|
|
375
|
-
whether the `OUTBOX_LISTENER_REGISTRAR` provider is bound — so the
|
|
376
|
-
handler fires exactly once.
|
|
105
|
+
`CqrsTransactionalModule.forRoot()` wraps handlers at bootstrap:
|
|
377
106
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
```
|
|
107
|
+
- **Command handlers** carrying `@Transactional()` (method- or
|
|
108
|
+
class-level). Set `defaultCommandOptions` to wrap them all.
|
|
109
|
+
- **Query handlers** — wrapped read-only by default
|
|
110
|
+
(`defaultQueryOptions: { readOnly: true }`). Pass `undefined` to opt
|
|
111
|
+
out. Note that `readOnly` is enforced by the database only on
|
|
112
|
+
Postgres-family dialects.
|
|
113
|
+
- **Event handlers** only when they carry `@Transactional()`. There is
|
|
114
|
+
no kind-level default, because event handlers are often out-of-band
|
|
115
|
+
side effects where a transaction is the wrong thing.
|
|
390
116
|
|
|
391
|
-
|
|
117
|
+
Async configuration works the same way, with one wrinkle:
|
|
392
118
|
|
|
393
119
|
```ts
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
})
|
|
120
|
+
CqrsTransactionalModule.forRootAsync({
|
|
121
|
+
imports: [ConfigModule],
|
|
122
|
+
inject: [ConfigService],
|
|
123
|
+
useFactory: (cfg: ConfigService) => ({ wrapQueryHandlers: cfg.get('WRAP') !== 'false' }),
|
|
124
|
+
// Structural, so it stays outside the factory: it decides whether the
|
|
125
|
+
// EventPublisher override provider exists at all, and NestJS needs
|
|
126
|
+
// provider tokens before any factory has run.
|
|
127
|
+
useTransactionalEventPublisher: true,
|
|
128
|
+
});
|
|
398
129
|
```
|
|
399
130
|
|
|
400
|
-
|
|
401
|
-
defaults to the class name — so class renames invalidate stored
|
|
402
|
-
publications unless `options.id` is set.
|
|
403
|
-
|
|
404
|
-
## Worked examples
|
|
131
|
+
## Choosing a handler decorator
|
|
405
132
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
133
|
+
| | Persisted | Retried | Survives restart |
|
|
134
|
+
| --- | --- | --- | --- |
|
|
135
|
+
| `@TransactionalEventsHandler` | no | no | no |
|
|
136
|
+
| `@OutboxEventsHandler` *(outbox package)* | yes | yes | yes |
|
|
137
|
+
| `@IntegrationEventsHandler` | if the outbox is wired | if wired | if wired |
|
|
410
138
|
|
|
411
|
-
|
|
139
|
+
Use `@TransactionalEventsHandler` for in-process work that is fine to
|
|
140
|
+
lose on a crash — cache invalidation, metrics. Use
|
|
141
|
+
`@OutboxEventsHandler` when at-least-once delivery matters: external
|
|
142
|
+
API calls, emails, billing.
|
|
412
143
|
|
|
413
|
-
|
|
144
|
+
`@IntegrationEventsHandler` is the one to reach for by default in
|
|
145
|
+
cross-module code. It routes through the outbox when
|
|
146
|
+
`OUTBOX_LISTENER_REGISTRAR` is bound and falls back to in-memory
|
|
147
|
+
delivery when it is not — decided at bootstrap by module wiring, not at
|
|
148
|
+
the call site. The same handler therefore runs in-memory during early
|
|
149
|
+
development and durably once a worker exists, without touching the
|
|
150
|
+
handler. It mirrors Spring Modulith's `@ApplicationModuleListener`.
|
|
414
151
|
|
|
415
|
-
|
|
416
|
-
`Scope.DEFAULT` (singleton), `Scope.REQUEST`, and `Scope.TRANSIENT` —
|
|
417
|
-
since [ADR-020](../../docs/adr/020-prototype-level-cqrs-wrapping.md):
|
|
418
|
-
the wrap is applied to the handler class **prototype**, which
|
|
419
|
-
intercepts `@nestjs/cqrs`'s late-bound `instance.execute(query)` lookup
|
|
420
|
-
regardless of how the instance is resolved.
|
|
421
|
-
|
|
422
|
-
A common request-scoped pattern uses `@nestjs/cqrs`'s own `AsyncContext`
|
|
423
|
-
mechanism to carry per-request data (user, geo, A/B flags, ...) into
|
|
424
|
-
the handler via the standard `REQUEST` token:
|
|
152
|
+
To turn on durable delivery, bind both structural ports:
|
|
425
153
|
|
|
426
154
|
```ts
|
|
427
|
-
|
|
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
|
-
}
|
|
155
|
+
providers: [
|
|
156
|
+
{ provide: OUTBOX_PUBLICATION_SCHEDULER, useExisting: OutboxEventPublisher },
|
|
157
|
+
{ provide: OUTBOX_LISTENER_REGISTRAR, useExisting: OutboxListenerRegistry },
|
|
158
|
+
];
|
|
440
159
|
```
|
|
441
160
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
161
|
+
A rollback then undoes all of it: no in-memory handler fires, no
|
|
162
|
+
publication row persists, nothing downstream runs.
|
|
163
|
+
|
|
164
|
+
Listener ids are `${baseId}#${EventName}`, with `baseId` defaulting to
|
|
165
|
+
the class name — so pass an explicit `id` if the class may be renamed,
|
|
166
|
+
or stored publications will be orphaned.
|
|
448
167
|
|
|
449
168
|
## Limitations
|
|
450
169
|
|
|
451
|
-
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
##
|
|
468
|
-
|
|
469
|
-
|
|
170
|
+
- **`eventBus.publish(...)` bypasses the dispatcher.** Only events
|
|
171
|
+
emitted by an aggregate through `mergeObjectContext` /
|
|
172
|
+
`mergeClassContext` and `commit()` become phase-aware.
|
|
173
|
+
- **Arrow-function class fields are not wrapped.** The wrap point is the
|
|
174
|
+
prototype, and `execute = async (q) => {}` shadows it. Use method
|
|
175
|
+
syntax.
|
|
176
|
+
- **`@nestjs/cqrs@11` only**, deliberately, while the other peers accept
|
|
177
|
+
`^10 || ^11`. The wrapping mechanism would work on v10, but
|
|
178
|
+
`AsyncContext` — which request-scoped handler support depends on —
|
|
179
|
+
does not exist there, and advertising `^10` would promise a documented
|
|
180
|
+
feature that cannot work.
|
|
181
|
+
|
|
182
|
+
Handlers of any scope are supported, including `Scope.REQUEST` and
|
|
183
|
+
`Scope.TRANSIENT`, because the wrap is applied to the prototype
|
|
184
|
+
([ADR-020](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/020-prototype-level-cqrs-wrapping.md)).
|
|
185
|
+
|
|
186
|
+
## Documentation
|
|
187
|
+
|
|
188
|
+
- [Getting started and full docs](https://github.com/igorgolovanov/nestjs-transactional#readme)
|
|
189
|
+
- [Transactional events and Spring semantics (ADR-002)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/002-transactional-events-spring-semantics.md)
|
|
190
|
+
- [Handler API design (ADR-014)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/adr/014-handler-api-redesign.md)
|
|
191
|
+
- [Why `@nestjs/cqrs` is not forked (DD-002)](https://github.com/igorgolovanov/nestjs-transactional/blob/main/docs/dd/002-no-fork-nestjs-cqrs.md)
|
|
192
|
+
- Runnable examples:
|
|
193
|
+
[`basic-cqrs`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/basic-cqrs),
|
|
194
|
+
[`multi-datasource-cqrs`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/multi-datasource-cqrs),
|
|
195
|
+
[`saga-pattern`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/saga-pattern),
|
|
196
|
+
[`e-commerce-orders`](https://github.com/igorgolovanov/nestjs-transactional/tree/main/examples/e-commerce-orders)
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT
|
|
@@ -27,7 +27,7 @@ export interface IntegrationEventsHandlerOptions {
|
|
|
27
27
|
readonly id?: string;
|
|
28
28
|
/**
|
|
29
29
|
* dataSource the in-memory dispatcher fallback path attaches phase
|
|
30
|
-
* hooks to
|
|
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
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"integration-events-handler.decorator.js","sourceRoot":"","sources":["../../src/decorators/integration-events-handler.decorator.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"file":"integration-events-handler.decorator.js","sourceRoot":"","sources":["../../src/decorators/integration-events-handler.decorator.ts"],"names":[],"mappings":";;;AA4HA,4DAeC;AAmCD,kFAKC;AAnLD,4BAA0B;AAG1B,qDAAsE;AAEtE;;;;;;;GAOG;AACU,QAAA,mCAAmC,GAAG,MAAM,CAAC,qCAAqC,CAAC,CAAC;AA+GjG,SAAgB,wBAAwB,CACtC,GAAG,IAAgD;IAEnD,MAAM,QAAQ,GAAqC,eAAe,CAAC,IAAI,CAAC,CAAC;IAEzE,IAAI,QAAQ,CAAC,UAAU,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CACb,8DAA8D;YAC5D,uEAAuE,CAC1E,CAAC;IACJ,CAAC;IAED,OAAO,CAAC,MAAc,EAAQ,EAAE;QAC9B,OAAO,CAAC,cAAc,CAAC,2CAAmC,EAAE,QAAQ,EAAE,MAAM,CAAC,CAAC;IAChF,CAAC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CACtB,IAAgD;IAEhD,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC,IAAI,eAAe,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAClD,MAAM,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;QACxB,OAAO;YACL,UAAU,EAAE,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC;YAC/B,EAAE,EAAE,OAAO,CAAC,EAAE;YACd,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,+BAAwB;SAC3D,CAAC;IACJ,CAAC;IAED,OAAO;QACL,UAAU,EAAE,IAAc;QAC1B,UAAU,EAAE,+BAAwB;KACrC,CAAC;AACJ,CAAC;AAED,SAAS,eAAe,CAAC,SAAkB;IACzC,OAAO,CACL,SAAS,KAAK,IAAI;QAClB,OAAO,SAAS,KAAK,QAAQ;QAC7B,CAAC,KAAK,CAAC,OAAO,CAAC,SAAS,CAAC;QACzB,OAAO,SAAS,KAAK,UAAU;QAC/B,QAAQ,IAAI,SAAS,CACtB,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,SAAgB,mCAAmC,CACjD,MAAc;IAEd,MAAM,KAAK,GAAY,OAAO,CAAC,WAAW,CAAC,2CAAmC,EAAE,MAAM,CAAC,CAAC;IACxF,OAAO,KAAqD,CAAC;AAC/D,CAAC"}
|