@ddd-ts-kit/events 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.fr.md +719 -0
  2. package/README.md +239 -32
  3. package/package.json +3 -3
package/README.fr.md ADDED
@@ -0,0 +1,719 @@
1
+ [English](README.md) · **Français**
2
+ <!-- translation-of: README.md sha256:2e352f4884684a2a -->
3
+
4
+ # @ddd-ts-kit/events
5
+
6
+ Les événements d'intégration entre contextes délimités (bounded contexts), indépendants de tout
7
+ *broker* : des déclarations typées, un registre, une publication et une consommation validées,
8
+ une outbox transactionnelle, ainsi que le document AsyncAPI et le catalogue d'événements générés
9
+ à partir des déclarations.
10
+
11
+ ```
12
+ npm i @ddd-ts-kit/events @ddd-ts-kit/validation zod
13
+ ```
14
+
15
+ ![Un événement d'intégration à travers le package : dans la transaction de la commande, l'agrégat enregistre un événement du domaine, son publisher crée l'événement d'intégration à partir de sa déclaration et le publie vers le port IntegrationEventPublisher, lié à l'OutboxPublisher, qui ajoute un enregistrement en attente à la table outbox ; après la validation, l'OutboxRelay réclame l'enregistrement et le publie vers le broker ou le bus en mémoire, qui le livre au moins une fois à consume dans le contexte consommateur, qui le déduplique et lance le gestionnaire, ici la commande IssueInvoice. Une seule déclaration alimente le producteur, le consommateur, le registre, le document AsyncAPI et le catalogue](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/events-package-place.fr.svg)
16
+
17
+ ## Ce que contient le package
18
+
19
+ | Étape | À quoi elle sert | Exports |
20
+ |---|---|---|
21
+ | Déclarer | le contrat d'un événement et d'une version, qui appartient au producteur | `defineIntegrationEvent`, `PayloadOf` |
22
+ | Enregistrer | tous les événements qu'un système publie, pour valider ce qui sort et générer les documents | `createEventRegistry`, `EventRegistry`, `validatingPublisher` |
23
+ | Stocker | écrire l'événement dans la transaction de la commande | `OutboxPublisher`, le port `OutboxStore`, `InMemoryOutboxStore` |
24
+ | Relayer | publier ce qui a été stocké, après la validation (commit), avec de nouvelles tentatives | `OutboxRelay`, le port `RelaySignal`, `InMemoryRelaySignal` |
25
+ | Consommer | un traitement typé, validé, dédupliqué | `consume`, `on`, `subscribeAll` |
26
+ | Documenter | le document AsyncAPI et le catalogue d'événements | `generateAsyncApi` (`@ddd-ts-kit/events/asyncapi`), `generateEventCatalog` |
27
+
28
+ Les concepts qui le sous-tendent sont dans [L'outbox transactionnelle](../../docs/concepts/transactional-outbox.fr.md),
29
+ [Livraison et idempotence](../../docs/concepts/delivery-and-idempotency.fr.md) et
30
+ [Versionnement des événements](../../docs/concepts/event-versioning.fr.md).
31
+
32
+ ## Toute la boucle dans un seul processus
33
+
34
+ Tout ce qui suit est en mémoire, donc s'exécute tel quel : une déclaration, un consommateur,
35
+ l'outbox, le relais, et une nouvelle livraison que le consommateur absorbe. Une vraie application
36
+ remplace les pièces en mémoire par sa base de données et son *broker* ; le code du producteur et
37
+ celui du consommateur ne changent pas.
38
+
39
+ <!-- checked sample -->
40
+ ```ts
41
+ import { z } from 'zod';
42
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
43
+ import { InMemoryEventBus, InMemoryProcessedEventStore, InMemoryUnitOfWork, SystemClock } from '@ddd-ts-kit/kernel';
44
+ import {
45
+ consume,
46
+ defineIntegrationEvent,
47
+ InMemoryOutboxStore,
48
+ OutboxPublisher,
49
+ OutboxRelay,
50
+ subscribeAll,
51
+ } from '@ddd-ts-kit/events';
52
+
53
+ // 1. Le contrat, déclaré une seule fois par le producteur.
54
+ const orderPlaced = defineIntegrationEvent({
55
+ type: 'ordering.order.placed',
56
+ version: 1,
57
+ payload: zodSchema(z.object({ orderId: z.string(), total: z.number().int() })),
58
+ });
59
+
60
+ // 2. Un consommateur d'un autre contexte : dédupliqué, dans une seule unité de travail.
61
+ const bus = new InMemoryEventBus();
62
+ const invoiced: string[] = [];
63
+
64
+ subscribeAll(bus, [
65
+ consume(
66
+ orderPlaced,
67
+ {
68
+ consumer: 'billing.invoice-on-order-placed',
69
+ processed: new InMemoryProcessedEventStore(),
70
+ unitOfWork: new InMemoryUnitOfWork(),
71
+ },
72
+ async (event) => {
73
+ invoiced.push(event.payload.orderId); // typé : { orderId: string; total: number }
74
+ },
75
+ ),
76
+ ]);
77
+
78
+ // 3. Le producteur ne fait que stocker : rien ne part encore.
79
+ const store = new InMemoryOutboxStore();
80
+ const event = orderPlaced.create({ orderId: 'order-1', total: 3000 }, { id: 'evt-1', occurredAt: new Date() });
81
+
82
+ await new OutboxPublisher(store).publish([event]);
83
+ console.log(await store.stats()); // { pending: 1, failed: 0, dead: 0 }
84
+
85
+ // 4. Le relais publie ce qui a été stocké, puis le marque publié.
86
+ const relay = new OutboxRelay({ store, publisher: bus, clock: new SystemClock() });
87
+
88
+ console.log(await relay.tick()); // { published: 1, failed: 0 }
89
+
90
+ // 5. Le même événement livré une seconde fois est absorbé par le consommateur.
91
+ await bus.publish([event]);
92
+ console.log(invoiced); // [ 'order-1' ]
93
+ ```
94
+
95
+ Les sections qui suivent reprennent chaque étape, telle qu'une vraie application l'écrit.
96
+
97
+ ## Déclarer
98
+
99
+ Une déclaration par événement et par version, dans le `published-language/` du producteur. Elle
100
+ donne le type de la charge utile (payload), valide ce qui est publié et consommé, et alimente
101
+ les générateurs.
102
+
103
+ <!-- checked sample -->
104
+ ```ts
105
+ // contexts/ordering/published-language/order-placed.v1.integration-event.ts
106
+ import { z } from 'zod';
107
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
108
+ import { defineIntegrationEvent, type PayloadOf } from '@ddd-ts-kit/events';
109
+
110
+ export const orderPlaced = defineIntegrationEvent({
111
+ type: 'ordering.order.placed', // <context>.<aggregate>.<fact>, segments en kebab-case
112
+ version: 1,
113
+ description: 'An order was placed by a customer.',
114
+ payload: zodSchema(
115
+ z.object({
116
+ orderId: z.string().describe('Identifier of the order.'),
117
+ total: z.object({ amount: z.number().int(), currency: z.enum(['EUR', 'USD']) }),
118
+ lines: z.array(z.object({ sku: z.string(), quantity: z.number().int().positive() })),
119
+ }),
120
+ ),
121
+ });
122
+
123
+ export type OrderPlaced = PayloadOf<typeof orderPlaced>;
124
+ ```
125
+
126
+ Le type est vérifié au chargement du module : trois segments en kebab-case,
127
+ `<context>.<aggregate>.<fact>`, et une version entière positive, sinon `defineIntegrationEvent`
128
+ lève `InvalidEventDefinitionError`. Le producteur est le premier segment du type, sauf si
129
+ `producer` le nomme. La déclaration répond ensuite à trois questions, une par côté :
130
+
131
+ | Membre | Côté | Ce qu'il fait |
132
+ |---|---|---|
133
+ | `create(payload, { id, occurredAt, correlationId?, traceparent? })` | producteur | valide la charge utile et construit l'enveloppe ; lève `InvalidEventPayloadError`, car publier un événement invalide est un bug |
134
+ | `parse(event)` | consommateur | vérifie le type, la version et la charge utile et renvoie un `Result` ; ne lève jamais d'exception |
135
+ | `is(event)` | l'un ou l'autre | une garde peu coûteuse sur le type et la version seulement, pour une charge utile déjà digne de confiance |
136
+
137
+ Un changement cassant est une nouvelle déclaration avec `version: 2` ; marquez l'ancienne
138
+ `deprecated: true` et continuez de publier les deux jusqu'à ce que les consommateurs aient migré.
139
+ Au lieu de changer la charge utile de `version: 1` dans le dos de ses consommateurs :
140
+
141
+ <!-- checked sample -->
142
+ ```ts
143
+ // contexts/ordering/published-language/order-placed.v2.integration-event.ts
144
+ import { z } from 'zod';
145
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
146
+ import { defineIntegrationEvent } from '@ddd-ts-kit/events';
147
+
148
+ // `amount` renommé `minorUnits`, toute devise ISO acceptée : un consommateur de la version 1 casserait, donc une nouvelle version.
149
+ export const orderPlacedV2 = defineIntegrationEvent({
150
+ type: 'ordering.order.placed',
151
+ version: 2,
152
+ description: 'An order was placed by a customer.',
153
+ payload: zodSchema(
154
+ z.object({
155
+ orderId: z.string(),
156
+ total: z.object({ minorUnits: z.number().int(), currency: z.string().length(3) }),
157
+ }),
158
+ ),
159
+ });
160
+
161
+ // order-placed.v1.integration-event.ts garde sa charge utile et gagne `deprecated: true` ;
162
+ // le publisher publie les deux versions jusqu'à ce qu'aucun consommateur ne lise la version 1.
163
+ ```
164
+
165
+ Un consommateur abonné à la version 1 ignore la version 2, et inversement : les deux cohabitent.
166
+ Le document AsyncAPI liste les deux versions sur le canal du type, et le catalogue marque celle
167
+ qui est dépréciée.
168
+
169
+ ## Publier par l'outbox
170
+
171
+ Un *publisher* est un gestionnaire d'un événement du domaine de son propre contexte : il traduit
172
+ le fait en charge utile publique et publie vers un `IntegrationEventPublisher`, sans rien savoir
173
+ d'autre. Avec une base de données, ce *publisher* est l'`OutboxPublisher` : il ne fait que
174
+ stocker. Les événements du domaine sont distribués à la fin de l'unité de travail de la
175
+ commande, avant sa validation (la phase `before-commit`, celle par défaut), si bien que
176
+ l'événement d'intégration est validé ou annulé avec l'écriture métier : le relais le publie
177
+ après la validation, jamais pour une commande annulée. Un *publisher* n'est jamais déclaré
178
+ `phase: 'after-commit'` : la transaction qui porterait sa ligne d'outbox est terminée.
179
+
180
+ ```ts
181
+ // contexts/ordering/application/integration/publishers/order-placed.publisher.ts
182
+ import { IdGenerator, IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
183
+ import { DomainEventHandler } from '@ddd-ts-kit/kernel/declarations';
184
+
185
+ @DomainEventHandler(OrderPlacedEvent, { name: OrderPlacedEvent.eventName })
186
+ export class OrderPlacedPublisher implements DomainEventHandler<OrderPlacedEvent> {
187
+ constructor(
188
+ private readonly publisher: IntegrationEventPublisher,
189
+ private readonly ids: IdGenerator,
190
+ ) {}
191
+
192
+ async handle(event: OrderPlacedEvent): Promise<void> {
193
+ const published = orderPlaced.create(
194
+ { orderId: event.aggregateId, total: event.total, lines: event.lines },
195
+ { id: this.ids.next(), occurredAt: event.occurredAt },
196
+ ); // lève InvalidEventPayloadError : publier un événement invalide est un bug
197
+
198
+ await this.publisher.publish([published]);
199
+ }
200
+ }
201
+ ```
202
+
203
+ L'identifiant de corrélation ne se passe pas à la main : `scopedPublisher` l'appose (voir « Le
204
+ contexte d'exécution d'un événement consommé »).
205
+
206
+ Le *publisher* connaît le port, jamais le transport. Au lieu de parler à un *broker* depuis le
207
+ gestionnaire, ce qui envoie l'événement même quand la transaction est ensuite annulée :
208
+
209
+ ```ts
210
+ // Signalé par events/outbox-required : l'événement part avant la validation, ou sans elle.
211
+ export class OrderPlacedPublisher {
212
+ constructor(private readonly bus: RabbitMqEventBus) {}
213
+ }
214
+ ```
215
+
216
+ écrivez vers `IntegrationEventPublisher`, comme ci-dessus, et laissez la racine de composition
217
+ décider où il va. La règle `events/outbox-required` signale deux choses. Dans `domain/` ou
218
+ `application/`, l'import d'un client de *broker*, de n'importe quel bus d'événements (le port
219
+ `EventBus` du noyau compris), de `OutboxRelay`, de `OutboxPublisher` ou de `OutboxStore`. Dans
220
+ `application/integration/publishers/`, un paramètre de constructeur typé comme un bus ou un relais
221
+ (un type dont le nom finit par `EventBus` ou `Relay`) : c'est ainsi que l'exemple ci-dessus est
222
+ signalé, avec ou sans import.
223
+
224
+ La racine de composition lie le stockage sous son port, `OutboxStore`, et remet l'outbox au
225
+ module du noyau comme *publisher* qu'utilisent les gestionnaires. Les ports sont des classes
226
+ abstraites, et c'est la classe qu'un module lie et résout :
227
+
228
+ ```ts
229
+ // platform/messaging.module.ts
230
+ import { defineContextModule } from '@ddd-ts-kit/di';
231
+ import { OutboxStore } from '@ddd-ts-kit/events';
232
+
233
+ export const messagingModule = defineContextModule({
234
+ name: 'messaging',
235
+ register(builder) {
236
+ // L'adaptateur de votre base de données, ou InMemoryOutboxStore dans un seul processus.
237
+ builder.bind(OutboxStore).toClass(SqlOutboxStore, [Database]);
238
+ },
239
+ });
240
+ ```
241
+
242
+ <!-- checked sample -->
243
+ ```ts
244
+ // main.ts
245
+ import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
246
+ import { Clock, ExecutionScope, Logger, scopedPublisher, type IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
247
+ import {
248
+ createEventRegistry,
249
+ OutboxPublisher,
250
+ OutboxRelay,
251
+ OutboxStore,
252
+ validatingPublisher,
253
+ type AnyIntegrationEventDefinition,
254
+ } from '@ddd-ts-kit/events';
255
+
256
+ // Écrits ailleurs dans le projet.
257
+ declare const persistenceModule: ContextModule, messagingModule: ContextModule, orderingModule: ContextModule;
258
+ declare const orderPlaced: AnyIntegrationEventDefinition, invoiceIssued: AnyIntegrationEventDefinition;
259
+ declare const broker: IntegrationEventPublisher; // le vrai fournisseur : un adaptateur de broker, ou le bus en mémoire
260
+
261
+ const registry = createEventRegistry([orderPlaced, invoiceIssued]);
262
+
263
+ const app = createApplication({
264
+ modules: [
265
+ kernelModule({
266
+ // Ce vers quoi publient les gestionnaires : l'outbox, derrière le registre, avec la corrélation apposée.
267
+ eventPublisher: (resolve) =>
268
+ scopedPublisher(
269
+ validatingPublisher(new OutboxPublisher(resolve(OutboxStore)), registry, { unknown: 'reject' }),
270
+ resolve(ExecutionScope),
271
+ ),
272
+ }),
273
+ persistenceModule,
274
+ messagingModule,
275
+ orderingModule,
276
+ ],
277
+ });
278
+ const logger = app.resolve(Logger);
279
+
280
+ const relay = new OutboxRelay({
281
+ store: app.resolve(OutboxStore),
282
+ publisher: broker,
283
+ clock: app.resolve(Clock),
284
+ maxAttempts: 8,
285
+ onFailure: (failure) => logger.warn('outbox relay failure', { ...failure }), // des codes seulement, jamais un message d'erreur
286
+ });
287
+ relay.start({ intervalMs: 500 });
288
+ process.on('SIGTERM', () => void relay.stop()); // attend la fin du tour en cours
289
+ ```
290
+
291
+ Donnez l'outbox à `kernelModule({ eventPublisher })` plutôt que de lier de nouveau
292
+ `IntegrationEventPublisher` dans un module ultérieur : l'`instrumentation` du module du noyau
293
+ enveloppe le *publisher* qu'il lie, et celui-là seulement ; un *publisher* lié ailleurs n'a donc
294
+ pas de *span* `outbox append` et ne stocke aucun `traceparent` avec ses événements.
295
+
296
+ Un adaptateur `implements OutboxStore` et ne l'étend jamais : le port n'a que des membres
297
+ abstraits. Sans conteneur, les mêmes objets se construisent à la main :
298
+ `new OutboxPublisher(store)`, `new OutboxRelay({ store, publisher: broker, clock })`.
299
+
300
+ L'outbox porte aussi les demandes de purge des caches HTTP (`CacheInvalidation` du noyau) : le
301
+ relais publie par `new PurgingPublisher(purger, broker)`, qui les remet au `CachePurger` au lieu
302
+ du *broker*. Voir « Tags de cache et purge après la validation » dans le README de
303
+ `@ddd-ts-kit/http`.
304
+
305
+ `relay.tick()` réclame un lot, publie chaque événement, le marque publié, ou planifie une
306
+ nouvelle tentative avec une attente exponentielle (backoff) ; après `maxAttempts`,
307
+ l'enregistrement est `dead` jusqu'à `store.retryDead(ids, now)`. `store.stats()` donne
308
+ `{ pending, failed, dead }` pour une vérification de santé, `store.purgePublished(before)` garde
309
+ la table petite.
310
+
311
+ ![La vie d'un enregistrement de l'outbox : ajouté en attente par la validation de la commande, réclamé par le relais quand il est dû, publié puis purgé ; une publication en échec le laisse en attente jusqu'à sa tentative suivante, après une attente exponentielle ; après maxAttempts tentatives, il est mort jusqu'à ce que retryDead le remette en attente](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/transactional-outbox-record-lifecycle.fr.svg)
312
+
313
+ Le relais publie les enregistrements d'un lot un par un, si bien qu'un événement refusé par le
314
+ fournisseur ne fait pas échouer ses voisins. Avec les valeurs par défaut (`baseMs: 1000`,
315
+ `factor: 2`, `maxMs: 300000`, `maxAttempts: 8`), un enregistrement qui échoue toujours suit ce
316
+ parcours :
317
+
318
+ | Tentative en échec | Tentative suivante | Ce que reçoit `onFailure` |
319
+ |---|---|---|
320
+ | 1 | 1 s plus tard | `{ kind: 'publish', eventId, eventType, code, attempts: 1, dead: false }` |
321
+ | 2 | 2 s plus tard | `attempts: 2, dead: false` |
322
+ | 3 à 7 | 4, 8, 16, 32, puis 64 s plus tard | `attempts` de 3 à 7, `dead: false` |
323
+ | 8 | aucune : l'enregistrement est `dead` | `attempts: 8, dead: true` |
324
+
325
+ Le délai vaut `min(maxMs, baseMs * factor ** (attempts - 1))` ; passez
326
+ `backoff: { baseMs, factor, maxMs }` pour le changer. `code` est le `code` de l'erreur quand il a
327
+ la forme d'un identifiant, sinon le nom de sa classe : jamais son message, qui peut citer la
328
+ charge utile, si bien que `onFailure` peut être journalisé tel quel.
329
+
330
+ **Le traçage à travers l'outbox.** L'enveloppe porte `traceparent`, et chaque stockage d'outbox
331
+ le persiste et le restitue exactement (la suite de contrat le vérifie). Avec
332
+ `kernelModule({ instrumentation: traceApplication(), eventPublisher: outbox })`, le contexte de
333
+ la requête HTTP est apposé quand l'événement est ajouté, et
334
+ `new OutboxRelay({ store, publisher: tracedPublisher(broker), clock })` poursuit cette trace
335
+ quand il publie plus tard : le relais n'a pas de *span* à lui, et un tour qui ne publie rien
336
+ n'en crée aucun. `OutboxPublisher` se nomme lui-même `messagingSystem: 'outbox'`, ce que
337
+ `validatingPublisher` et `scopedPublisher` transmettent. Voir le README de
338
+ `@ddd-ts-kit/tracing`.
339
+
340
+ La livraison est au moins une fois. **Par défaut, l'ordre n'est pas garanti, pas même par
341
+ agrégat** : un événement en échec est retenté pendant que ses successeurs partent, et plusieurs
342
+ relais peuvent tourner côte à côte. Quand l'ordre compte, portez une séquence ou une version dans
343
+ la charge utile, ou demandez-le au relais (ci-dessous).
344
+
345
+ ### Réveiller le relais au lieu d'interroger souvent
346
+
347
+ Un relais peut dormir sur un long intervalle et publier quand même tout de suite, quand le
348
+ stockage lui signale qu'un ajout a eu lieu. `wakeOn` prend un `RelaySignal`, un port d'une seule
349
+ méthode (`subscribe(listener): { unsubscribe() }`) : `LISTEN/NOTIFY` de PostgreSQL, un flux de
350
+ changements (change stream), ou `InMemoryRelaySignal` dans un seul processus. Un module le lie
351
+ comme le stockage, `builder.bind(RelaySignal).toClass(PostgresRelaySignal, [Database])`, et le
352
+ relais prend `wakeOn: app.resolve(RelaySignal)`.
353
+
354
+ ```ts
355
+ const signal = new InMemoryRelaySignal();
356
+ const store = new InMemoryOutboxStore({ signal }); // notifie après chaque ajout
357
+ const relay = new OutboxRelay({ store, publisher: broker, clock, wakeOn: signal });
358
+ relay.start({ intervalMs: 30_000 }); // l'intervalle n'est plus que le filet de sécurité
359
+ ```
360
+
361
+ Les rafales sont regroupées : un tour s'exécute, au plus un autre est mis en file, quel que soit
362
+ le nombre de notifications. **L'interrogation périodique (polling) reste le filet de
363
+ sécurité** : un signal est un indice, et une notification manquée pendant que l'écouteur se
364
+ reconnecte ou que le processus redémarre ne doit que retarder la publication jusqu'à l'intervalle
365
+ suivant, jamais la perdre. Gardez donc `intervalMs` au plus long délai que vous acceptez dans ce
366
+ cas, et ne vous fiez jamais au seul signal. Un stockage transactionnel doit notifier après la
367
+ validation (PostgreSQL le fait de lui-même : `NOTIFY` est délivré à la validation).
368
+
369
+ ### L'ordre par clé
370
+
371
+ ```ts
372
+ const relay = new OutboxRelay({
373
+ store,
374
+ publisher: broker,
375
+ clock,
376
+ ordering: 'per-key',
377
+ keyOf: (event) => (orderPlaced.is(event) ? event.payload.orderId : undefined),
378
+ });
379
+ ```
380
+
381
+ Avec `ordering: 'per-key'`, un enregistrement n'est jamais publié tant qu'un enregistrement
382
+ antérieur de même clé est encore en attente ou en échec. Le prix est le blocage en tête de file
383
+ (head-of-line blocking) : un événement qui ne peut pas être publié retient tous les événements
384
+ suivants de sa clé (et de sa clé seulement) jusqu'à ce qu'il parte ou soit mort. Les événements
385
+ pour lesquels `keyOf` donne `undefined` ne sont pas ordonnés.
386
+
387
+ ![Cinq enregistrements dans l'outbox, dans l'ordre de leur ajout : A1, A2, B1, A3, B2, où A est la clé order-1 et B la clé order-2, et la publication de A1 échoue. Avec l'ordre par défaut, A2, B1, A3 et B2 sont publiés dans le même tour et A1 est retenté plus tard, si bien que A2 et A3 doublent A1. Avec l'ordre per-key, B1 et B2 sont publiés, A2 et A3 sont retenus avec le code OUTBOX_HELD_FOR_ORDER, et une fois A1 parti lors de sa nouvelle tentative, A2 puis A3 suivent dans l'ordre](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/events-package-per-key-ordering.fr.svg)
388
+
389
+ Ce sur quoi il repose, et ce qu'il ne donne pas :
390
+
391
+ - **Faites tourner un seul relais.** La mémoire de quel enregistrement bloque quelle clé vit dans
392
+ le relais, pas dans le stockage : le port `OutboxStore` est inchangé, et `claimBatch` seul ne
393
+ peut pas savoir qu'un enregistrement antérieur attend sa nouvelle tentative. Deux relais côte à
394
+ côte publient en concurrence.
395
+ - **Un redémarrage l'oublie.** Si le processus redémarre pendant qu'un enregistrement est en
396
+ attente exponentielle ou sous bail, un enregistrement ultérieur de même clé qui devient dû le
397
+ premier est publié le premier, une fois. Un ordre strict à travers les redémarrages exige la
398
+ clé stockée avec l'enregistrement et une réclamation qui saute les clés ayant une ligne
399
+ antérieure non publiée : un changement du port et de la table, qui n'est pas fait ici.
400
+ - Un enregistrement retenu est replanifié avec la tête de sa clé et apparaît dans
401
+ `stats().failed` avec le code `OUTBOX_HELD_FOR_ORDER` ; les réclamations qu'il passe à attendre
402
+ ne sont pas comptées comme des tentatives de livraison par ce relais.
403
+ - Un enregistrement **mort** cesse de bloquer sa clé (`onFailure` le signale avec `dead: true`).
404
+ Réglez `maxAttempts: Infinity` si une clé doit plutôt rester bloquée que sauter un événement.
405
+ - Le *broker* et le consommateur doivent eux aussi garder l'ordre : une partition ou une file
406
+ d'attente avec un seul consommateur actif par clé, et un consommateur qui ne retente pas dans
407
+ le désordre.
408
+
409
+ Un `OutboxStore` accorde un bail sur ce qu'il remet : `claimBatch` masque les enregistrements
410
+ réclamés pendant le bail du stockage, si bien qu'un relais qui meurt les laisse revenir. Les
411
+ adaptateurs sont tenus par `outboxStoreContract` de `@ddd-ts-kit/testing/contracts`.
412
+
413
+ ## Consommer de façon idempotente
414
+
415
+ Avec `@ddd-ts-kit/di`, un gestionnaire d'événement d'intégration déclaré est idempotent sans
416
+ rien écrire : sa marque d'idempotence et son travail s'exécutent dans une seule unité de travail
417
+ dès qu'un `UnitOfWork` et un `ProcessedEventStore` sont liés, ce que fait `kernelModule()`. La
418
+ racine de composition ne construit aucun gestionnaire à la main :
419
+
420
+ ```ts
421
+ // contexts/billing/application/integration/handlers/invoice-on-order-placed.integration-handler.ts
422
+ import { CommandBus, type IntegrationEvent } from '@ddd-ts-kit/kernel';
423
+ import { IntegrationEventHandler } from '@ddd-ts-kit/kernel/declarations';
424
+ import { orderPlaced, type OrderPlaced } from '@/contexts/ordering/published-language/index';
425
+
426
+ @IntegrationEventHandler(orderPlaced, { consumer: 'billing.invoice-on-order-placed' })
427
+ export class InvoiceOnOrderPlaced {
428
+ constructor(private readonly commands: CommandBus) {}
429
+
430
+ async handle(event: IntegrationEvent<OrderPlaced>): Promise<void> {
431
+ await this.commands.execute(new IssueInvoice(event.payload.orderId, event.payload.total));
432
+ }
433
+ }
434
+ ```
435
+
436
+ `consume` est l'outil d'un projet qui câble sans di. Au lieu d'abonner une simple fonction de
437
+ rappel, qui traite une seconde fois un événement livré de nouveau et émet une seconde facture :
438
+
439
+ ```ts
440
+ // Signalé par events/consumer-idempotent dans application/ et dans le fichier du module :
441
+ // pas de nom de consommateur, pas de stockage des événements traités, et une charge utile ni typée ni validée.
442
+ subscriber.subscribe(orderPlaced.type, async (event) => {
443
+ const { orderId, total } = event.payload as OrderPlaced;
444
+ await commandBus.execute(new IssueInvoice(orderId, total));
445
+ });
446
+ ```
447
+
448
+ donnez à une simple fonction la même sémantique qu'au gestionnaire déclaré :
449
+
450
+ ```ts
451
+ // contexts/billing/billing.subscriptions.ts
452
+ import { consume, subscribeAll } from '@ddd-ts-kit/events';
453
+
454
+ export const billingSubscriptions = ({ commandBus, processed, unitOfWork }: BillingDependencies) => [
455
+ consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork }, async (event) => {
456
+ await commandBus.execute(new IssueInvoice(event.payload.orderId, event.payload.total));
457
+ }),
458
+ ];
459
+
460
+ // main.ts
461
+ const subscription = subscribeAll(subscriber, billingSubscriptions(dependencies));
462
+ ```
463
+
464
+ Le gestionnaire reçoit un événement typé et validé. Un événement d'une autre version du même type
465
+ est ignoré (abonnez-vous-y avec sa propre déclaration) ; une charge utile invalide lève une
466
+ exception, si bien que le fournisseur retente ou envoie en file de rejet (dead letter).
467
+
468
+ `consume` exécute la déduplication et le gestionnaire dans **une seule unité de travail** : la
469
+ marque dans le stockage des événements traités et le changement d'état du consommateur sont
470
+ validés ensemble. Une nouvelle livraison est traitée une fois par consommateur, et un événement
471
+ dont le gestionnaire a levé une exception est traité de nouveau quand il revient, car sa marque a
472
+ été annulée avec le reste. Le *middleware* du bus de commandes rejoint cette unité de travail (un
473
+ `run` imbriqué rejoint la transaction englobante), si bien que le gestionnaire n'a besoin de rien
474
+ de particulier.
475
+
476
+ ![Une livraison à travers consume : la déclaration analyse l'événement ; un autre type ou une autre version est ignoré sans marque ; une charge utile invalide lève InvalidEventPayloadError, si bien que le fournisseur la retente ou l'envoie en file de rejet. Un événement valide entre dans une seule unité de travail, dans la portée d'exécution quand elle est fournie : le stockage des événements traités marque le couple du consommateur et de l'identifiant de l'événement ; quand le couple était déjà marqué, le gestionnaire est sauté ; sinon le gestionnaire s'exécute, et son succès valide la marque avec le changement d'état, tandis que son échec annule les deux, si bien que la nouvelle livraison est traitée](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/events-package-consume.fr.svg)
477
+
478
+ Le nom du consommateur est la portée de l'idempotence : deux consommateurs du même événement le
479
+ traitent chacun une fois, et renommer un consommateur lui fait traiter de nouveau chaque
480
+ événement qui lui est relivré. Écrivez-le une fois, sous la forme `<context>.<reaction>`, et ne le
481
+ dérivez jamais.
482
+
483
+ `on(definition, handler, { consumer, processed, unitOfWork })` est la même chose avec des parties
484
+ facultatives, pour un consommateur qui n'a pas besoin de déduplication ou n'a pas de transaction.
485
+ Attention à un cas : avec `processed` et sans `unitOfWork`, la marque est posée avant que le
486
+ gestionnaire s'exécute. Si le gestionnaire échoue ensuite, un stockage transactionnel garde la
487
+ marque (elle a été validée seule) et la nouvelle livraison est ignorée : l'événement est perdu
488
+ pour ce consommateur. Sans base de données, utilisez un stockage qui peut retirer sa marque, un
489
+ `ReleasableProcessedEventStore` comme l'`InMemoryProcessedEventStore` du noyau : `on` et
490
+ `consume` libèrent la marque d'un gestionnaire qui a échoué. `ReleasableProcessedEventStore` est
491
+ un type, pas un port à part entière : c'est le port `ProcessedEventStore` du noyau avec son
492
+ `release` facultatif rendu obligatoire, et un tel stockage se lie sous `ProcessedEventStore`.
493
+
494
+ ### Le contexte d'exécution d'un événement consommé
495
+
496
+ `consume` et `on` prennent l'`ExecutionScope` du noyau et exécutent tout, l'unité de travail et
497
+ la marque d'idempotence comprises, à l'intérieur :
498
+
499
+ ```ts
500
+ consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, unitOfWork, scope }, handler);
501
+ ```
502
+
503
+ | Champ du contexte | Valeur |
504
+ |---|---|
505
+ | `origin` | `event` |
506
+ | `correlationId` | le `correlationId` de l'événement, ou son `id` quand il n'en nomme aucun |
507
+ | `causationId` | l'`id` de l'événement |
508
+ | `actor` | `systemActor(consumer)`, sauf si `actorOf(event)` en renvoie un |
509
+ | `tenantId` | le `tenantId` de l'événement, quand il en nomme un : le consommateur ne lit et n'écrit que dans ce *tenant* |
510
+
511
+ Ainsi, les *repositories* du contexte consommateur remplissent les colonnes d'audit avec
512
+ `system:billing.invoice-on-order-placed`, et une ligne de journal du consommateur porte la
513
+ corrélation de la requête HTTP qui a tout déclenché. L'enveloppe ne porte aucun acteur par
514
+ défaut, à dessein : un acteur est une donnée personnelle, et un *broker* conserve les messages.
515
+ Un projet qui décide de mettre une référence dans sa propre charge utile ou sa propre enveloppe
516
+ la traduit avec `actorOf: (event) => Actor | undefined`.
517
+
518
+ Côté publication, `scopedPublisher` du noyau remplit la corrélation, et le *tenant* du travail
519
+ en cours, si bien que les cas d'usage ne les passent jamais à la main. C'est l'enveloppe la plus
520
+ externe du *publisher* donné à `kernelModule` dans le `main.ts` ci-dessus :
521
+
522
+ ```ts
523
+ eventPublisher: (resolve) => scopedPublisher(new OutboxPublisher(resolve(OutboxStore)), resolve(ExecutionScope)),
524
+ ```
525
+
526
+ Un événement créé avec un `correlationId` ou un `tenantId` le garde ; un événement publié hors de
527
+ tout contexte d'exécution est laissé tel quel. C'est un décorateur, pas une option
528
+ d'`OutboxPublisher` ni de `defineIntegrationEvent(...).create` : les deux restent purs, et
529
+ l'apposition s'applique à tout fournisseur.
530
+
531
+ ## Générer le document AsyncAPI et le catalogue
532
+
533
+ <!-- checked sample -->
534
+ ```ts
535
+ // scripts/generate-contracts.ts
536
+ import { readFile, writeFile } from 'node:fs/promises';
537
+ import { generateEventCatalog, type EventRegistry } from '@ddd-ts-kit/events';
538
+ import { checkAsyncApiDrift, generateAsyncApi, stringifyAsyncApi } from '@ddd-ts-kit/events/asyncapi';
539
+
540
+ declare const registry: EventRegistry; // createEventRegistry([...toutes les déclarations publiées])
541
+
542
+ const document = generateAsyncApi({
543
+ info: { title: 'Shop events', version: '1.4.0' },
544
+ registry,
545
+ servers: { production: { host: 'broker.example.com:5671', protocol: 'amqps' } },
546
+ });
547
+ await writeFile('docs/integration/asyncapi.json', stringifyAsyncApi(document));
548
+ await writeFile(
549
+ 'docs/integration/event-catalog.md',
550
+ generateEventCatalog(registry, { consumers: { 'ordering.order.placed': ['billing', 'shipping'] } }),
551
+ );
552
+
553
+ // En CI : échouer quand le fichier commité est périmé.
554
+ if (checkAsyncApiDrift(document, await readFile('docs/integration/asyncapi.json', 'utf8')).drift) {
555
+ process.exit(1);
556
+ }
557
+ ```
558
+
559
+ `stringifyAsyncApi` écrit du JSON indenté de deux espaces. Un projet généré par la CLI commite
560
+ plutôt `docs/integration/asyncapi.yaml`, écrit par `stringifyContract(document, 'yaml')` de
561
+ `ddd-ts-kit/dist/contracts.js` dans son `scripts/events.ts` (`openapi.format: json` de sa
562
+ configuration garde le JSON).
563
+
564
+ Le document AsyncAPI 3.0.0 a un canal par type d'événement (son adresse est le type), un message
565
+ par version dont la charge utile est l'enveloppe (`id`, `type`, `version`, `occurredAt`,
566
+ `payload`, `traceparent`, `correlationId`), et une opération `send` par canal. Le catalogue est
567
+ en Markdown : une section par producteur, un tableau des versions par événement, et les champs
568
+ de la charge utile mis à plat (`customer.email`, `lines[].sku`). Les deux sorties ne dépendent
569
+ que des déclarations, jamais de leur ordre d'enregistrement, si bien qu'on peut les commiter et
570
+ les comparer.
571
+ Un projet généré par la CLI a ce script sous le nom `scripts/events.ts`, avec les deux mêmes
572
+ chemins.
573
+
574
+ ## Choisir la stratégie pour votre transport
575
+
576
+ Il n'y a qu'une règle, quels que soient les outils : le gestionnaire écrit l'événement dans
577
+ l'outbox dans la transaction de la commande, un relais le publie ensuite, le consommateur
578
+ déduplique. Ce qui change avec le transport, c'est la façon dont le relais tourne et ce que le
579
+ côté consommateur doit faire.
580
+
581
+ | Transport | Relais | Côté consommateur |
582
+ |---|---|---|
583
+ | même processus, sans *broker* | relais par interrogation périodique vers le bus en mémoire (ou réveillé par la base de données : `LISTEN/NOTIFY` de PostgreSQL) | stockage des événements traités dans la transaction du consommateur |
584
+ | RabbitMQ et autres *brokers* à files d'attente | relais par interrogation périodique qui publie avec confirmations de publication (publisher confirms), marquage publié à la confirmation | une file d'attente durable par consommateur, acquittement manuel après la validation, échange de rejet (dead-letter exchange) après un nombre borné de nouvelles tentatives |
585
+ | Kafka et autres journaux | relais par interrogation périodique, ou capture des changements de données (change data capture) qui lit la table outbox (outbox router de Debezium) ; clé = identifiant de l'agrégat pour l'ordre par agrégat | un groupe de consommateurs par consommateur, *offset* validé après la transaction du gestionnaire, stockage des événements traités pour les nouvelles livraisons |
586
+ | files et sujets du cloud (SQS/SNS, Pub/Sub) | relais par interrogation périodique, publication par lots | délai de visibilité supérieur au pire cas du gestionnaire, politique de redirection (redrive policy) vers une file de rejet |
587
+
588
+ Sans base de données, il n'y a rien avec quoi être atomique : publiez directement vers le bus,
589
+ sans outbox.
590
+
591
+ Le kit a un *package* fournisseur pour RabbitMQ (`@ddd-ts-kit/rabbitmq`), Kafka
592
+ (`@ddd-ts-kit/kafka`), NATS JetStream (`@ddd-ts-kit/nats`) et Redis Streams
593
+ (`@ddd-ts-kit/redis/event-bus`) ; chaque README dit comment il remplit les exigences ci-dessous et
594
+ ce qu'il ne garantit pas.
595
+
596
+ ### Même processus, sans broker
597
+
598
+ - **Mode du relais.** `OutboxRelay` qui publie vers l'`InMemoryEventBus`. Réveillez-le avec
599
+ `wakeOn` (le signal en mémoire, ou `LISTEN/NOTIFY` sur PostgreSQL) et gardez un long intervalle
600
+ comme filet de sécurité.
601
+ - **Exigences du publisher.** Aucune au-delà du port. Le bus en mémoire exécute les gestionnaires
602
+ dans `publish` et rejette quand l'un d'eux échoue : le relais retente alors l'enregistrement, si
603
+ bien que la politique de nouvelles tentatives des consommateurs EST l'attente exponentielle du
604
+ relais.
605
+ - **Exigences du consommateur.** `consume` avec le stockage des événements traités et l'unité de
606
+ travail du contexte consommateur. Une nouvelle livraison exécute de nouveau chaque gestionnaire
607
+ du type, si bien que chacun d'eux déduplique.
608
+ - **Ordre.** `per-key` le donne ici sans rien d'autre à mettre en place : un relais, un
609
+ processus, des gestionnaires attendus dans l'ordre (la réserve du relais sur le redémarrage
610
+ s'applique toujours).
611
+ - **File de rejet.** L'outbox est le stockage des rejets : un enregistrement est `dead` après
612
+ `maxAttempts`, et revient avec `store.retryDead(ids, now)`.
613
+ - **Surveiller.** `stats()` : `dead > 0` est une alerte ; un `failed` qui monte signifie qu'un
614
+ consommateur échoue sans cesse ; l'âge de l'enregistrement en attente le plus ancien est le
615
+ délai de livraison.
616
+
617
+ ### Brokers à files d'attente (RabbitMQ, ActiveMQ)
618
+
619
+ - **Mode du relais.** Relais par interrogation périodique ou réveillé. L'enregistrement n'est
620
+ marqué publié que lorsque le *broker* l'a confirmé.
621
+ - **Exigences du publisher.** `publish` ne doit se résoudre qu'à la confirmation du *broker*
622
+ (publisher confirms) et rejeter sinon, avec des échanges durables et des messages persistants.
623
+ Une confirmation perdue après que le *broker* a pris le message fait republier le relais : les
624
+ doublons sont attendus. `@ddd-ts-kit/rabbitmq` met en œuvre cette ligne.
625
+ - **Exigences du consommateur.** Une file d'attente durable par consommateur et par type
626
+ d'événement, un acquittement manuel après la validation de l'unité de travail du gestionnaire,
627
+ `consume` pour la déduplication. N'acquittez jamais avant la validation.
628
+ - **Ordre.** Aucun par défaut : les nouvelles tentatives et les consommateurs concurrents
629
+ réordonnent. Par clé seulement avec le relais `per-key` ET un seul consommateur actif sur la
630
+ file ET aucune file de nouvelles tentatives, ce qui en vaut rarement la peine ; préférez une
631
+ version dans la charge utile.
632
+ - **File de rejet.** Deux endroits. Côté publication : l'outbox (`dead`, `retryDead`). Côté
633
+ consommation : un nombre borné de nouvelles tentatives différées, puis une file de rejet par
634
+ file de consommateur.
635
+ - **Surveiller.** Les `stats()` de l'outbox, et sur le *broker* la profondeur de chaque file de
636
+ travail (un consommateur qui prend du retard), de chaque file de rejet (qui doit être vide) et
637
+ le nombre de messages non acquittés.
638
+
639
+ ### Journaux (Kafka, Redpanda, Pulsar, Kinesis)
640
+
641
+ - **Mode du relais.** Relais par interrogation périodique, ou capture des changements de données
642
+ (ci-dessous).
643
+ - **Exigences du publisher.** Un producteur idempotent avec `acks=all` ; `publish` se résout à
644
+ l'acquittement de l'écriture. La clé du message est l'identifiant de l'agrégat, si bien qu'un
645
+ agrégat vit dans une seule partition. Un sujet (topic) par type d'événement ou par contexte,
646
+ choisi une fois pour toutes.
647
+ - **Exigences du consommateur.** Un groupe de consommateurs par contexte consommateur. L'*offset*
648
+ est validé après la transaction du gestionnaire, jamais avant ; un plantage entre les deux
649
+ provoque une nouvelle livraison, d'où le stockage des événements traités. Un gestionnaire qui
650
+ échoue sans cesse bloque sa partition : retentez sur place un nombre borné de fois, puis
651
+ publiez l'enregistrement vers un sujet de rejet et validez.
652
+ - **Ordre.** Par partition, donc par clé, pourvu que le relais le garde : `ordering: 'per-key'`
653
+ avec un `keyOf` qui renvoie le même identifiant d'agrégat, un seul relais, et
654
+ `max.in.flight.requests.per.connection` à 5 au plus avec l'idempotence activée.
655
+ - **File de rejet.** L'outbox côté publication, un sujet de rejet par groupe de consommateurs
656
+ côté consommation.
657
+ - **Surveiller.** Les `stats()` de l'outbox, le retard des consommateurs par groupe et par
658
+ partition, la taille du sujet de rejet.
659
+
660
+ ### Files et sujets du cloud (SQS/SNS, Pub/Sub)
661
+
662
+ - **Mode du relais.** Relais par interrogation périodique. La publication par lots (10 entrées
663
+ par appel sur SQS et SNS) est permise, mais le résultat est par entrée : publiez, puis rejetez
664
+ si une entrée a échoué, pour que le relais retente ; les entrées qui sont passées sont publiées
665
+ deux fois, ce que le consommateur absorbe.
666
+ - **Exigences du publisher.** Un sujet avec un abonnement (une file d'attente) par consommateur.
667
+ `publish` se résout à l'acquittement du service. Les attributs du message portent le type, la
668
+ version et la trace.
669
+ - **Exigences du consommateur.** Un délai de visibilité (ack deadline) supérieur au pire cas du
670
+ gestionnaire, prolongé pendant son exécution si besoin ; supprimez (acquittez) le message après
671
+ la validation. `consume` pour la déduplication : ces services livrent de nouveau par
672
+ construction.
673
+ - **Ordre.** Aucun sur les files standard. Les files FIFO et les clés d'ordre le donnent par
674
+ groupe de messages, au prix du débit, avec le relais `per-key` et l'identifiant de groupe =
675
+ `keyOf`.
676
+ - **File de rejet.** L'outbox côté publication ; une politique de redirection
677
+ (`maxReceiveCount`) vers une file de rejet par file de consommateur.
678
+ - **Surveiller.** Les `stats()` de l'outbox, l'âge du message le plus ancien par file, la
679
+ profondeur de la file de rejet.
680
+
681
+ ### Ce qu'il faut surveiller sur l'outbox
682
+
683
+ `store.stats()` donne `{ pending, failed, dead }` ; exposez-les comme des jauges.
684
+
685
+ | Signal | Sens | Réaction |
686
+ |---|---|---|
687
+ | `dead > 0` | des enregistrements ont abandonné après `maxAttempts` | alerte ; corrigez la cause, puis `retryDead` |
688
+ | `failed > 0` longtemps | le fournisseur refuse ou est hors service ; sous `per-key`, les enregistrements retenus comptent aussi ici (`OUTBOX_HELD_FOR_ORDER`) | regardez les codes donnés à `onFailure` |
689
+ | `pending` qui grossit | le relais est plus lent que les producteurs, ou arrêté | vérifiez qu'il tourne ; augmentez `batchSize` |
690
+ | âge de l'enregistrement en attente le plus ancien | le vrai délai de livraison | alerte au-delà de votre tolérance ; c'est une requête sur la table (`min(created_at) where status = 'pending'`), `stats()` ne le porte pas |
691
+ | `onFailure({ kind: 'tick' })` | le stockage lui-même a échoué | santé de la base de données |
692
+
693
+ Purgez régulièrement les enregistrements publiés (`purgePublished(before)`), sinon la table et
694
+ son index grossissent sans limite.
695
+
696
+ ### Où se place la capture des changements de données
697
+
698
+ La capture des changements de données (Debezium et son outbox event router, ou un lecteur de
699
+ réplication logique) remplace **le relais, jamais la table**. Les gestionnaires écrivent toujours
700
+ l'événement dans la table outbox dans la transaction de la commande ; au lieu d'un processus qui
701
+ interroge cette table, la capture lit les mêmes lignes dans le journal des transactions de la
702
+ base de données et les publie, dans l'ordre des validations. Conséquences :
703
+
704
+ - rien ne change dans les gestionnaires, dans `OutboxPublisher` ni dans les consommateurs ;
705
+ - ne faites pas tourner un `OutboxRelay` sur la même table pour la même destination : l'un ou
706
+ l'autre ;
707
+ - `status`, `attempts` et `next_attempt_at` ne sont plus tenus à jour : les nouvelles tentatives
708
+ et les échecs sont ceux du connecteur, donc surveillez le connecteur (retard, tâches en échec)
709
+ au lieu de `stats()`, et purgez par âge ;
710
+ - l'event router doit faire correspondre les colonnes : `id` à l'identifiant du message, `type`
711
+ au sujet ou à la clé de routage, l'identifiant de l'agrégat à la clé du message, `payload` et
712
+ les colonnes de trace au corps et aux en-têtes, pour que le consommateur reçoive la même
713
+ enveloppe que depuis le relais ;
714
+ - l'ordre par clé vient gratuitement (le journal est ordonné), et c'est la principale raison de
715
+ la choisir.
716
+
717
+ Écrire vers le *broker* depuis le gestionnaire et « capturer » plutôt les tables métier n'est
718
+ pas ce patron : cela publie des lignes, pas des événements, et couple les consommateurs au
719
+ schéma.
package/README.md CHANGED
@@ -1,3 +1,5 @@
1
+ **English** · [Français](README.fr.md)
2
+
1
3
  # @ddd-ts-kit/events
2
4
 
3
5
  Integration events between bounded contexts, independent of any broker: typed declarations,
@@ -8,11 +10,94 @@ document and event catalog generated from the declarations.
8
10
  npm i @ddd-ts-kit/events @ddd-ts-kit/validation zod
9
11
  ```
10
12
 
13
+ ![One integration event through the package: inside the transaction of the command, the aggregate records a domain event, its publisher creates the integration event from its declaration and publishes it to the IntegrationEventPublisher port, bound to the OutboxPublisher, which appends a pending record to the outbox table; after the commit the OutboxRelay claims the record and publishes it to the broker or the in-memory bus, which delivers it at least once to consume in the consuming context, which deduplicates it and runs the handler, here the command IssueInvoice. One declaration feeds the producer, the consumer, the registry, the AsyncAPI document and the catalog](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/events-package-place.svg)
14
+
15
+ ## What is in the package
16
+
17
+ | Step | What it is for | Exports |
18
+ |---|---|---|
19
+ | Declare | the contract of one event and version, owned by the producer | `defineIntegrationEvent`, `PayloadOf` |
20
+ | Register | every event a system publishes, to validate what goes out and to generate the documents | `createEventRegistry`, `EventRegistry`, `validatingPublisher` |
21
+ | Store | write the event in the transaction of the command | `OutboxPublisher`, the `OutboxStore` port, `InMemoryOutboxStore` |
22
+ | Relay | publish what was stored, after the commit, with retries | `OutboxRelay`, the `RelaySignal` port, `InMemoryRelaySignal` |
23
+ | Consume | typed, validated, deduplicated handling | `consume`, `on`, `subscribeAll` |
24
+ | Document | the AsyncAPI document and the event catalog | `generateAsyncApi` (`@ddd-ts-kit/events/asyncapi`), `generateEventCatalog` |
25
+
26
+ The concepts behind it are in [The transactional outbox](../../docs/concepts/transactional-outbox.md),
27
+ [Delivery and idempotency](../../docs/concepts/delivery-and-idempotency.md) and
28
+ [Event versioning](../../docs/concepts/event-versioning.md).
29
+
30
+ ## The whole loop in one process
31
+
32
+ Everything below is in memory, so it runs as it is: one declaration, a consumer, the outbox,
33
+ the relay, and a redelivery the consumer absorbs. A real application swaps the in-memory
34
+ pieces for its database and its broker; the code of the producer and of the consumer does not
35
+ change.
36
+
37
+ <!-- checked sample -->
38
+ ```ts
39
+ import { z } from 'zod';
40
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
41
+ import { InMemoryEventBus, InMemoryProcessedEventStore, InMemoryUnitOfWork, SystemClock } from '@ddd-ts-kit/kernel';
42
+ import {
43
+ consume,
44
+ defineIntegrationEvent,
45
+ InMemoryOutboxStore,
46
+ OutboxPublisher,
47
+ OutboxRelay,
48
+ subscribeAll,
49
+ } from '@ddd-ts-kit/events';
50
+
51
+ // 1. The contract, declared once by the producer.
52
+ const orderPlaced = defineIntegrationEvent({
53
+ type: 'ordering.order.placed',
54
+ version: 1,
55
+ payload: zodSchema(z.object({ orderId: z.string(), total: z.number().int() })),
56
+ });
57
+
58
+ // 2. A consumer of another context: deduplicated, in one unit of work.
59
+ const bus = new InMemoryEventBus();
60
+ const invoiced: string[] = [];
61
+
62
+ subscribeAll(bus, [
63
+ consume(
64
+ orderPlaced,
65
+ {
66
+ consumer: 'billing.invoice-on-order-placed',
67
+ processed: new InMemoryProcessedEventStore(),
68
+ unitOfWork: new InMemoryUnitOfWork(),
69
+ },
70
+ async (event) => {
71
+ invoiced.push(event.payload.orderId); // typed: { orderId: string; total: number }
72
+ },
73
+ ),
74
+ ]);
75
+
76
+ // 3. The producer only stores: nothing leaves yet.
77
+ const store = new InMemoryOutboxStore();
78
+ const event = orderPlaced.create({ orderId: 'order-1', total: 3000 }, { id: 'evt-1', occurredAt: new Date() });
79
+
80
+ await new OutboxPublisher(store).publish([event]);
81
+ console.log(await store.stats()); // { pending: 1, failed: 0, dead: 0 }
82
+
83
+ // 4. The relay publishes what was stored, then marks it published.
84
+ const relay = new OutboxRelay({ store, publisher: bus, clock: new SystemClock() });
85
+
86
+ console.log(await relay.tick()); // { published: 1, failed: 0 }
87
+
88
+ // 5. The same event delivered again is absorbed by the consumer.
89
+ await bus.publish([event]);
90
+ console.log(invoiced); // [ 'order-1' ]
91
+ ```
92
+
93
+ The sections below take each step in turn, as a real application writes it.
94
+
11
95
  ## Declare
12
96
 
13
97
  One declaration per event and version, in the producer's `published-language/`. It gives the
14
98
  payload type, validates what is published and consumed, and feeds the generators.
15
99
 
100
+ <!-- checked sample -->
16
101
  ```ts
17
102
  // contexts/ordering/published-language/order-placed.v1.integration-event.ts
18
103
  import { z } from 'zod';
@@ -35,8 +120,48 @@ export const orderPlaced = defineIntegrationEvent({
35
120
  export type OrderPlaced = PayloadOf<typeof orderPlaced>;
36
121
  ```
37
122
 
123
+ The type is checked when the module loads: three kebab-case segments, `<context>.<aggregate>.<fact>`,
124
+ and a positive integer version, or `defineIntegrationEvent` throws `InvalidEventDefinitionError`.
125
+ The producer is the first segment of the type unless `producer` names it. The declaration then
126
+ answers three questions, one per side:
127
+
128
+ | Member | Side | What it does |
129
+ |---|---|---|
130
+ | `create(payload, { id, occurredAt, correlationId?, traceparent? })` | producer | validates the payload and builds the envelope; throws `InvalidEventPayloadError`, because publishing an invalid event is a bug |
131
+ | `parse(event)` | consumer | checks type, version and payload and answers a `Result`; never throws |
132
+ | `is(event)` | either | a cheap guard on type and version only, for a payload already trusted |
133
+
38
134
  A breaking change is a new declaration with `version: 2`; mark the old one `deprecated: true`
39
- and keep publishing both until the consumers have moved.
135
+ and keep publishing both until the consumers have moved. Instead of changing the payload of
136
+ `version: 1` under the feet of its consumers:
137
+
138
+ <!-- checked sample -->
139
+ ```ts
140
+ // contexts/ordering/published-language/order-placed.v2.integration-event.ts
141
+ import { z } from 'zod';
142
+ import { zodSchema } from '@ddd-ts-kit/validation/zod';
143
+ import { defineIntegrationEvent } from '@ddd-ts-kit/events';
144
+
145
+ // `amount` renamed `minorUnits`, any ISO currency accepted: a consumer of version 1 would break, so a new version.
146
+ export const orderPlacedV2 = defineIntegrationEvent({
147
+ type: 'ordering.order.placed',
148
+ version: 2,
149
+ description: 'An order was placed by a customer.',
150
+ payload: zodSchema(
151
+ z.object({
152
+ orderId: z.string(),
153
+ total: z.object({ minorUnits: z.number().int(), currency: z.string().length(3) }),
154
+ }),
155
+ ),
156
+ });
157
+
158
+ // order-placed.v1.integration-event.ts keeps its payload and gains `deprecated: true`;
159
+ // the publisher publishes both versions until no consumer reads version 1.
160
+ ```
161
+
162
+ A consumer subscribed to version 1 skips version 2 and the other way round, so both live side
163
+ by side. The AsyncAPI document lists both versions on the channel of the type, and the catalog
164
+ marks the deprecated one.
40
165
 
41
166
  ## Publish through the outbox
42
167
 
@@ -74,44 +199,82 @@ export class OrderPlacedPublisher implements DomainEventHandler<OrderPlacedEvent
74
199
  The correlation id is not passed by hand: `scopedPublisher` stamps it (see "The execution
75
200
  context of a consumed event").
76
201
 
77
- The composition root binds the store under its port, `OutboxStore`, and replaces the
78
- publisher of the kernel by the outbox. Ports are abstract classes and the class is what a
79
- module binds and resolves:
202
+ The publisher knows the port, never the transport. Instead of talking to a broker from the
203
+ handler, which sends the event even when the transaction then rolls back:
204
+
205
+ ```ts
206
+ // Reported by events/outbox-required: the event leaves before the commit, or without it.
207
+ export class OrderPlacedPublisher {
208
+ constructor(private readonly bus: RabbitMqEventBus) {}
209
+ }
210
+ ```
211
+
212
+ write to `IntegrationEventPublisher`, as above, and let the composition root decide where it
213
+ goes. The rule `events/outbox-required` reports two things. In `domain/` or `application/`, an
214
+ import of a broker client, of any event bus (the `EventBus` port of the kernel included), of
215
+ `OutboxRelay`, of `OutboxPublisher` or of `OutboxStore`. In `application/integration/publishers/`,
216
+ a constructor parameter typed as a bus or a relay (a type whose name ends in `EventBus` or
217
+ `Relay`): the sample above is reported this way, import or not.
218
+
219
+ The composition root binds the store under its port, `OutboxStore`, and hands the outbox to
220
+ the kernel module as the publisher handlers use. Ports are abstract classes and the class is
221
+ what a module binds and resolves:
80
222
 
81
223
  ```ts
82
224
  // platform/messaging.module.ts
83
225
  import { defineContextModule } from '@ddd-ts-kit/di';
84
- import { IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
85
- import { createEventRegistry, OutboxPublisher, OutboxStore, validatingPublisher } from '@ddd-ts-kit/events';
86
-
87
- const registry = createEventRegistry([orderPlaced, invoiceIssued]);
226
+ import { OutboxStore } from '@ddd-ts-kit/events';
88
227
 
89
228
  export const messagingModule = defineContextModule({
90
229
  name: 'messaging',
91
230
  register(builder) {
92
231
  // The adapter of your database, or InMemoryOutboxStore in a single process.
93
232
  builder.bind(OutboxStore).toClass(SqlOutboxStore, [Database]);
94
- builder
95
- .rebind(IntegrationEventPublisher)
96
- .toFactory((resolve) =>
97
- validatingPublisher(new OutboxPublisher(resolve(OutboxStore)), registry, { unknown: 'reject' }),
98
- );
99
233
  },
100
234
  });
101
235
  ```
102
236
 
237
+ <!-- checked sample -->
103
238
  ```ts
104
239
  // main.ts
105
- import { createApplication, kernelModule } from '@ddd-ts-kit/di';
106
- import { Clock, Logger } from '@ddd-ts-kit/kernel';
107
- import { OutboxRelay, OutboxStore } from '@ddd-ts-kit/events';
240
+ import { createApplication, kernelModule, type ContextModule } from '@ddd-ts-kit/di';
241
+ import { Clock, ExecutionScope, Logger, scopedPublisher, type IntegrationEventPublisher } from '@ddd-ts-kit/kernel';
242
+ import {
243
+ createEventRegistry,
244
+ OutboxPublisher,
245
+ OutboxRelay,
246
+ OutboxStore,
247
+ validatingPublisher,
248
+ type AnyIntegrationEventDefinition,
249
+ } from '@ddd-ts-kit/events';
250
+
251
+ // Written elsewhere in the project.
252
+ declare const persistenceModule: ContextModule, messagingModule: ContextModule, orderingModule: ContextModule;
253
+ declare const orderPlaced: AnyIntegrationEventDefinition, invoiceIssued: AnyIntegrationEventDefinition;
254
+ declare const broker: IntegrationEventPublisher; // the real provider: a broker adapter, or the in-memory bus
108
255
 
109
- const app = createApplication({ modules: [kernelModule(), persistenceModule, messagingModule, orderingModule] });
256
+ const registry = createEventRegistry([orderPlaced, invoiceIssued]);
257
+
258
+ const app = createApplication({
259
+ modules: [
260
+ kernelModule({
261
+ // What handlers publish to: the outbox, behind the registry, with the correlation stamped.
262
+ eventPublisher: (resolve) =>
263
+ scopedPublisher(
264
+ validatingPublisher(new OutboxPublisher(resolve(OutboxStore)), registry, { unknown: 'reject' }),
265
+ resolve(ExecutionScope),
266
+ ),
267
+ }),
268
+ persistenceModule,
269
+ messagingModule,
270
+ orderingModule,
271
+ ],
272
+ });
110
273
  const logger = app.resolve(Logger);
111
274
 
112
275
  const relay = new OutboxRelay({
113
276
  store: app.resolve(OutboxStore),
114
- publisher: broker, // the real provider: any IntegrationEventPublisher
277
+ publisher: broker,
115
278
  clock: app.resolve(Clock),
116
279
  maxAttempts: 8,
117
280
  onFailure: (failure) => logger.warn('outbox relay failure', { ...failure }), // codes only, never an error message
@@ -120,6 +283,11 @@ relay.start({ intervalMs: 500 });
120
283
  process.on('SIGTERM', () => void relay.stop()); // waits for the tick in progress
121
284
  ```
122
285
 
286
+ Give the outbox to `kernelModule({ eventPublisher })` rather than rebinding
287
+ `IntegrationEventPublisher` in a later module: the `instrumentation` of the kernel module wraps
288
+ the publisher it binds, and only that one, so a publisher rebound elsewhere has no
289
+ `outbox append` span and stores no `traceparent` with its events.
290
+
123
291
  An adapter `implements OutboxStore` and never extends it: the port has abstract members only.
124
292
  Without a container the same objects are built by hand:
125
293
  `new OutboxPublisher(store)`, `new OutboxRelay({ store, publisher: broker, clock })`.
@@ -134,10 +302,28 @@ with an exponential backoff; after `maxAttempts` the record is `dead` until
134
302
  `store.retryDead(ids, now)`. `store.stats()` gives `{ pending, failed, dead }` for a health
135
303
  check, `store.purgePublished(before)` keeps the table small.
136
304
 
305
+ ![The life of an outbox record: appended pending by the commit of the command, claimed by the relay when it is due, published then purged; a failed publication leaves it pending until its next attempt, after an exponential backoff; after maxAttempts attempts it is dead until retryDead puts it back pending](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/transactional-outbox-record-lifecycle.svg)
306
+
307
+ The relay publishes the records of a batch one by one, so one event the provider refuses does
308
+ not fail its neighbours. With the defaults (`baseMs: 1000`, `factor: 2`, `maxMs: 300000`,
309
+ `maxAttempts: 8`) a record that always fails goes like this:
310
+
311
+ | Attempt that failed | Next attempt | `onFailure` receives |
312
+ |---|---|---|
313
+ | 1 | 1 s later | `{ kind: 'publish', eventId, eventType, code, attempts: 1, dead: false }` |
314
+ | 2 | 2 s later | `attempts: 2, dead: false` |
315
+ | 3 to 7 | 4, 8, 16, 32, then 64 s later | `attempts` from 3 to 7, `dead: false` |
316
+ | 8 | none: the record is `dead` | `attempts: 8, dead: true` |
317
+
318
+ The delay is `min(maxMs, baseMs * factor ** (attempts - 1))`; pass `backoff: { baseMs, factor, maxMs }`
319
+ to change it. `code` is the `code` of the error when it is shaped like an identifier, else its
320
+ class name: never its message, which may quote the payload, so `onFailure` can be logged as it
321
+ is.
322
+
137
323
  **Tracing across the outbox.** The envelope carries `traceparent`, and every outbox store
138
324
  persists and restores it exactly (the contract suite checks it). With
139
- `kernelModule({ instrumentation: traceApplication() })` the context of the request is stamped
140
- when the event is appended, and `new OutboxRelay({ store, publisher: tracedPublisher(broker), clock })`
325
+ `kernelModule({ instrumentation: traceApplication(), eventPublisher: outbox })` the context of
326
+ the request is stamped when the event is appended, and `new OutboxRelay({ store, publisher: tracedPublisher(broker), clock })`
141
327
  continues that trace when it publishes later: the relay has no span of its own, and a tick
142
328
  that publishes nothing makes none. `OutboxPublisher` names itself `messagingSystem: 'outbox'`,
143
329
  which `validatingPublisher` and `scopedPublisher` pass on. See the README of
@@ -188,6 +374,8 @@ key is still pending or failed. The price is head-of-line blocking: one event th
188
374
  published holds back every later event of its key (and only of its key) until it goes out or
189
375
  is dead. Events for which `keyOf` gives `undefined` are not ordered.
190
376
 
377
+ ![Five records in the outbox, in the order they were appended: A1, A2, B1, A3, B2, where A is the key order-1 and B the key order-2, and the publication of A1 fails. With the default ordering, A2, B1, A3 and B2 are published in the same tick and A1 is retried later, so A2 and A3 overtake A1. With ordering per-key, B1 and B2 are published, A2 and A3 are held with the code OUTBOX_HELD_FOR_ORDER, and once A1 goes out on its retry, A2 then A3 follow in order](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/events-package-per-key-ordering.svg)
378
+
191
379
  What it relies on, and what it does not give:
192
380
 
193
381
  - **Run a single relay.** The memory of which record blocks which key lives in the relay, not
@@ -232,8 +420,19 @@ export class InvoiceOnOrderPlaced {
232
420
  }
233
421
  ```
234
422
 
235
- `consume` is the tool of a project that wires without di. It gives the same semantics to a
236
- plain function:
423
+ `consume` is the tool of a project that wires without di. Instead of subscribing a plain
424
+ callback, which handles a redelivered event a second time and issues a second invoice:
425
+
426
+ ```ts
427
+ // Reported by events/consumer-idempotent in application/ and in the module file:
428
+ // no consumer name, no processed-event store, and a payload neither typed nor validated.
429
+ subscriber.subscribe(orderPlaced.type, async (event) => {
430
+ const { orderId, total } = event.payload as OrderPlaced;
431
+ await commandBus.execute(new IssueInvoice(orderId, total));
432
+ });
433
+ ```
434
+
435
+ give the same semantics as the declared handler to a plain function:
237
436
 
238
437
  ```ts
239
438
  // contexts/billing/billing.subscriptions.ts
@@ -260,6 +459,12 @@ back, because its mark was rolled back with the rest. The command bus middleware
260
459
  unit of work (a nested `run` joins the outer transaction), so the handler needs nothing
261
460
  special.
262
461
 
462
+ ![One delivery through consume: the declaration parses the event; another type or version is skipped without a mark; an invalid payload throws InvalidEventPayloadError, so the provider retries or dead-letters it. A valid event enters one unit of work, inside the execution scope when one is given: the processed-event store marks the pair of the consumer and the event id; when the pair was already marked, the handler is skipped; otherwise the handler runs, and its success commits the mark with the state change, while its failure rolls both back, so the redelivery is handled](https://raw.githubusercontent.com/gbourgeat/ddd-ts-kit/main/docs/assets/diagrams/events-package-consume.svg)
463
+
464
+ The consumer name is the idempotency scope: two consumers of the same event each handle it
465
+ once, and renaming a consumer makes it handle again every event it is redelivered. Write it
466
+ once, as `<context>.<reaction>`, and never derive it.
467
+
263
468
  `on(definition, handler, { consumer, processed, unitOfWork })` is the same thing with
264
469
  optional parts, for a consumer that needs no deduplication or has no transaction. Mind one
265
470
  case: with `processed` and no `unitOfWork`, the mark is taken before the handler runs. If the
@@ -286,6 +491,7 @@ consume(orderPlaced, { consumer: 'billing.invoice-on-order-placed', processed, u
286
491
  | `correlationId` | the `correlationId` of the event, or its `id` when it names none |
287
492
  | `causationId` | the `id` of the event |
288
493
  | `actor` | `systemActor(consumer)`, unless `actorOf(event)` answers one |
494
+ | `tenantId` | the `tenantId` of the event, when it names one: the consumer reads and writes in that tenant only |
289
495
 
290
496
  So the repositories of the consuming context stamp audit columns with
291
497
  `system:billing.invoice-on-order-placed`, and a log line of the consumer carries the correlation
@@ -293,28 +499,29 @@ of the request that started it all. The envelope carries no actor by default, on
293
499
  actor is personal data, and a broker keeps messages. A project that decides to put a reference
294
500
  in its own payload or envelope maps it with `actorOf: (event) => Actor | undefined`.
295
501
 
296
- On the publishing side, `scopedPublisher` of the kernel fills the correlation in, so use cases
297
- never pass it by hand:
502
+ On the publishing side, `scopedPublisher` of the kernel fills the correlation in, and the
503
+ tenant of the work in progress, so use cases never pass them by hand. It is the outer wrapper
504
+ of the publisher given to `kernelModule` in `main.ts` above:
298
505
 
299
506
  ```ts
300
- import { ExecutionScope, IntegrationEventPublisher, scopedPublisher } from '@ddd-ts-kit/kernel';
301
-
302
- builder
303
- .rebind(IntegrationEventPublisher) // what handlers publish to
304
- .toFactory((resolve) => scopedPublisher(new OutboxPublisher(resolve(OutboxStore)), resolve(ExecutionScope)));
507
+ eventPublisher: (resolve) => scopedPublisher(new OutboxPublisher(resolve(OutboxStore)), resolve(ExecutionScope)),
305
508
  ```
306
509
 
307
- An event created with a `correlationId` keeps it; one published outside of any execution
308
- context is left as it is. It is a decorator, not an option of `OutboxPublisher` or of
510
+ An event created with a `correlationId` or a `tenantId` keeps it; one published outside of any
511
+ execution context is left as it is. It is a decorator, not an option of `OutboxPublisher` or of
309
512
  `defineIntegrationEvent(...).create`: both stay pure, and the stamp applies to every provider.
310
513
 
311
514
  ## Generate the AsyncAPI document and the catalog
312
515
 
516
+ <!-- checked sample -->
313
517
  ```ts
314
518
  // scripts/generate-contracts.ts
315
- import { generateEventCatalog } from '@ddd-ts-kit/events';
519
+ import { readFile, writeFile } from 'node:fs/promises';
520
+ import { generateEventCatalog, type EventRegistry } from '@ddd-ts-kit/events';
316
521
  import { checkAsyncApiDrift, generateAsyncApi, stringifyAsyncApi } from '@ddd-ts-kit/events/asyncapi';
317
522
 
523
+ declare const registry: EventRegistry; // createEventRegistry([...every published declaration])
524
+
318
525
  const document = generateAsyncApi({
319
526
  info: { title: 'Shop events', version: '1.4.0' },
320
527
  registry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ddd-ts-kit/events",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Technology-agnostic integration events: typed declarations, registry, transactional outbox, AsyncAPI and catalog generation.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -30,8 +30,8 @@
30
30
  "dist"
31
31
  ],
32
32
  "dependencies": {
33
- "@ddd-ts-kit/kernel": "0.16.0",
34
- "@ddd-ts-kit/validation": "0.16.0"
33
+ "@ddd-ts-kit/kernel": "0.17.0",
34
+ "@ddd-ts-kit/validation": "0.17.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "zod": "^4.6.5"