@alveolus/arch 0.2.0 → 0.3.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 +6 -0
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-P5PwH9OE.mjs → docs-DsQHpTtV.mjs} +190 -5
- package/dist/docs-DsQHpTtV.mjs.map +1 -0
- package/dist/index.d.mts +44 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/docs/core/application/command-handlers.md +617 -0
- package/docs/core/application/event-publishers.md +234 -0
- package/docs/core/application/event-translators.md +329 -0
- package/docs/core/application/index.md +99 -0
- package/docs/core/application/integration-events.md +277 -0
- package/docs/core/application/outbox.md +416 -0
- package/docs/core/application/query-handlers.md +292 -0
- package/docs/core/application/unit-of-work.md +352 -0
- package/docs/core/domain/aggregates.md +822 -0
- package/docs/core/domain/domain-errors.md +251 -0
- package/docs/core/domain/domain-events.md +292 -0
- package/docs/core/domain/domain-services.md +249 -0
- package/docs/core/domain/entities.md +431 -0
- package/docs/core/domain/index.md +93 -0
- package/docs/core/domain/ports.md +284 -0
- package/docs/core/domain/repositories.md +335 -0
- package/docs/core/domain/value-objects.md +425 -0
- package/docs/core/domain/views.md +265 -0
- package/docs/core/index.md +108 -0
- package/docs/core/strategic/anti-corruption-layers.md +349 -0
- package/docs/core/strategic/index.md +83 -0
- package/docs/core/strategic/open-host-services.md +287 -0
- package/docs/core/strategic/published-language.md +265 -0
- package/docs/core/utilities/result.md +413 -0
- package/docs/guide/agents.md +68 -0
- package/docs/guide/existing-project.md +105 -0
- package/docs/guide/getting-started.md +275 -0
- package/docs/guide/learning-path.md +123 -0
- package/docs/guide/project-layout.md +324 -0
- package/docs/guide/versioning.md +42 -0
- package/docs/integrations/index.md +112 -0
- package/docs/integrations/nestjs.md +169 -0
- package/docs/rules/index.md +183 -0
- package/docs/rules/layers/no-driving-shortcut.md +119 -0
- package/docs/rules/layers/no-impure-domain.md +189 -0
- package/docs/rules/layers/no-outward-import.md +184 -0
- package/docs/rules/layers/no-portless-adapter.md +123 -0
- package/docs/rules/strategic/no-cross-context-import.md +140 -0
- package/docs/rules/strategic/no-fat-shared-kernel.md +81 -0
- package/docs/rules/strategic/no-leaky-host-service.md +107 -0
- package/docs/rules/strategic/no-unmapped-context.md +111 -0
- package/docs/rules/tactical/no-aggregate-reference.md +139 -0
- package/docs/rules/tactical/no-foreign-command-dependency.md +119 -0
- package/docs/rules/tactical/no-foreign-query-dependency.md +106 -0
- package/docs/rules/tactical/no-loose-code.md +171 -0
- package/docs/rules/tactical/no-misplaced-class.md +146 -0
- package/docs/rules/tactical/no-public-field.md +113 -0
- package/docs/rules/tactical/no-stateful-service.md +102 -0
- package/docs/rules/tactical/no-thrown-failure.md +162 -0
- package/docs/rules/tooling/no-loose-disable.md +98 -0
- package/package.json +4 -3
- package/dist/cli-P5PwH9OE.mjs.map +0 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Event publishers in TypeScript: the port that sends integration events to a message broker such as Kafka, a webhook, or other bounded contexts."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Event publishers
|
|
6
|
+
|
|
7
|
+
An event publisher is the port that sends [integration events](./integration-events.md) out of the
|
|
8
|
+
bounded context: to a broker, a webhook, or the other contexts of the same process.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Application (a port)</dd>
|
|
12
|
+
<dt>File</dt><dd><code>shared-kernel/driven/kafka/adapters/kafka-event-publisher.adapter.ts</code> (your adapter)</dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>EventPublisher</code></a></dd>
|
|
14
|
+
<dt>Called by</dt><dd>The <a href="/core/application/outbox"><code>OutboxRelay</code></a>, never a command handler</dd>
|
|
15
|
+
<dt>Checked by</dt><dd><a href="/rules/layers/no-portless-adapter"><code>layers/no-portless-adapter</code></a>, <a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Shipping must learn that an order was placed. The ordering context should not know whether that
|
|
21
|
+
goes through Kafka, RabbitMQ or a function call: if the relay used the Kafka client directly,
|
|
22
|
+
changing the broker, or testing without one, would mean changing application code.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The application talks to an abstract `EventPublisher` with one method, `publish(events)`. A driven
|
|
26
|
+
adapter implements it with the technology you use. The [outbox relay](./outbox.md) calls it; nothing
|
|
27
|
+
else does.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
`publish` receives a batch of integration events, already JSON, and must either send them all or
|
|
33
|
+
throw.
|
|
34
|
+
|
|
35
|
+
<div class="al-cards">
|
|
36
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Send in order</span>Publish the events in the order received: <code>OrderPlaced</code> before a later <code>OrderCancelled</code>.</div>
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Throw on failure</span>A broker down is technical: throw. The relay keeps the events pending and retries.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Expect duplicates</span>Delivery is at least once. Consumers ignore an event whose <code>id</code> they already handled.</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
## Where it fits
|
|
42
|
+
|
|
43
|
+
The publisher is the second step of the outbox relay, run in the background after the order was
|
|
44
|
+
saved.
|
|
45
|
+
|
|
46
|
+
<div class="al-diagram">
|
|
47
|
+
<svg viewBox="0 0 680 240" role="img" aria-label="A timer job calls the OutboxRelay, which reads pending events from the outbox, calls the event publisher, and marks the events as published.">
|
|
48
|
+
<defs>
|
|
49
|
+
<marker id="publisher-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
50
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
51
|
+
</marker>
|
|
52
|
+
</defs>
|
|
53
|
+
<rect class="box" x="8" y="92" width="130" height="56" rx="8" />
|
|
54
|
+
<text class="label" x="73" y="116" text-anchor="middle">Timer job</text>
|
|
55
|
+
<text class="note" x="73" y="136" text-anchor="middle">driving adapter</text>
|
|
56
|
+
<path class="link" d="M 138 120 L 178 120" marker-end="url(#publisher-flow-arrow)" />
|
|
57
|
+
<rect class="box" x="180" y="92" width="180" height="56" rx="8" />
|
|
58
|
+
<text class="label" x="270" y="116" text-anchor="middle">OutboxRelay</text>
|
|
59
|
+
<text class="note" x="270" y="136" text-anchor="middle">relay()</text>
|
|
60
|
+
<rect class="box" x="440" y="24" width="232" height="48" rx="8" />
|
|
61
|
+
<text class="label" x="556" y="44" text-anchor="middle">1 · outbox.pending(n)</text>
|
|
62
|
+
<text class="note" x="556" y="62" text-anchor="middle">reads a batch</text>
|
|
63
|
+
<rect class="boundary" x="440" y="96" width="232" height="48" rx="8" />
|
|
64
|
+
<text class="label" x="556" y="116" text-anchor="middle">2 · publisher.publish(events)</text>
|
|
65
|
+
<text class="note" x="556" y="134" text-anchor="middle">this page: sends them</text>
|
|
66
|
+
<rect class="box" x="440" y="168" width="232" height="48" rx="8" />
|
|
67
|
+
<text class="label" x="556" y="188" text-anchor="middle">3 · outbox.markPublished(ids)</text>
|
|
68
|
+
<text class="note" x="556" y="206" text-anchor="middle">skips them next time</text>
|
|
69
|
+
<path class="link" d="M 360 120 L 438 48" marker-end="url(#publisher-flow-arrow)" />
|
|
70
|
+
<path class="link" d="M 360 120 L 438 120" marker-end="url(#publisher-flow-arrow)" />
|
|
71
|
+
<path class="link" d="M 360 120 L 438 192" marker-end="url(#publisher-flow-arrow)" />
|
|
72
|
+
</svg>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
::: tip
|
|
76
|
+
If `publish` throws, step 3 never runs: the same events are published on the next call.
|
|
77
|
+
:::
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
import { EventPublisher } from "@alveolus/core";
|
|
83
|
+
// or: import { EventPublisher } from "@alveolus/core/event-publishers";
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `publish(events)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by the OutboxRelay" />
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
abstract publish(
|
|
90
|
+
events: readonly AnyIntegrationEvent[],
|
|
91
|
+
): Promise<void>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Sends the integration events, in order, and throws on failure. The `OutboxRelay` calls it with one
|
|
95
|
+
batch of pending events.
|
|
96
|
+
|
|
97
|
+
::: warning Caveats
|
|
98
|
+
- A failure to publish is technical: the adapter throws instead of returning a `Result`.
|
|
99
|
+
- Delivery is at least once: an event may be published twice. Consumers ignore duplicates by `id`.
|
|
100
|
+
- `EventPublisher` extends `Port`: it is an injection token, and its adapters live in
|
|
101
|
+
`driven/<technology>/adapters/`.
|
|
102
|
+
:::
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
Build a publisher for Kafka. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
|
|
107
|
+
|
|
108
|
+
<div class="al-cards">
|
|
109
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-pick-the-broker">Pick the broker</a></span>One adapter per technology.</div>
|
|
110
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-adapter">Declare the adapter</a></span>Extend the port.</div>
|
|
111
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-publish-each-event">Publish each event</a></span>In order, one topic per event type.</div>
|
|
112
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-key-each-message-by-its-id">Key each message by its id</a></span>Let consumers ignore duplicates.</div>
|
|
113
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-wire-it-to-the-relay">Wire it to the relay</a></span>Only the relay calls it.</div>
|
|
114
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
### 1. Pick the broker
|
|
118
|
+
|
|
119
|
+
`EventPublisher` is a port of `@alveolus/core`: you only write its adapter, under the name of its technology, here `driven/kafka/adapters/`, in the shared kernel.
|
|
120
|
+
|
|
121
|
+
### 2. Declare the adapter
|
|
122
|
+
|
|
123
|
+
The adapter extends `EventPublisher` and receives the client of the broker in its constructor, built by the composition root.
|
|
124
|
+
|
|
125
|
+
```ts [src/shared-kernel/driven/kafka/adapters/kafka-event-publisher.adapter.ts]
|
|
126
|
+
import { EventPublisher } from "@alveolus/core";
|
|
127
|
+
import type { Producer } from "kafkajs";
|
|
128
|
+
|
|
129
|
+
export class KafkaEventPublisher extends EventPublisher {
|
|
130
|
+
constructor(private readonly producer: Producer) {
|
|
131
|
+
super();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
TypeScript now asks for `publish()`: the next step adds it.
|
|
137
|
+
|
|
138
|
+
### 3. Publish each event
|
|
139
|
+
|
|
140
|
+
The relay hands over a batch in the order the events were added. Sending them one by one keeps that order, and a failure throws: the events stay pending in the outbox and are sent again.
|
|
141
|
+
|
|
142
|
+
```ts [src/shared-kernel/driven/kafka/adapters/kafka-event-publisher.adapter.ts]
|
|
143
|
+
import { EventPublisher } from "@alveolus/core"; // [!code --]
|
|
144
|
+
import { type AnyIntegrationEvent, EventPublisher } from "@alveolus/core"; // [!code ++]
|
|
145
|
+
import type { Producer } from "kafkajs";
|
|
146
|
+
|
|
147
|
+
export class KafkaEventPublisher extends EventPublisher {
|
|
148
|
+
constructor(private readonly producer: Producer) {
|
|
149
|
+
super();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async publish( // [!code ++]
|
|
153
|
+
events: readonly AnyIntegrationEvent[], // [!code ++]
|
|
154
|
+
): Promise<void> { // [!code ++]
|
|
155
|
+
for (const event of events) { // [!code ++]
|
|
156
|
+
await this.producer.send({ // [!code ++]
|
|
157
|
+
messages: [{ value: JSON.stringify(event) }], // [!code ++]
|
|
158
|
+
topic: `${event.source}.${event.type}`, // [!code ++]
|
|
159
|
+
}); // [!code ++]
|
|
160
|
+
} // [!code ++]
|
|
161
|
+
} // [!code ++]
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### 4. Key each message by its id
|
|
166
|
+
|
|
167
|
+
An event may be sent twice, after a failure between sending and marking it published. Its id, as the message key, lets consumers recognise and ignore a duplicate.
|
|
168
|
+
|
|
169
|
+
```ts [src/shared-kernel/driven/kafka/adapters/kafka-event-publisher.adapter.ts]
|
|
170
|
+
import { type AnyIntegrationEvent, EventPublisher } from "@alveolus/core";
|
|
171
|
+
import type { Producer } from "kafkajs";
|
|
172
|
+
|
|
173
|
+
export class KafkaEventPublisher extends EventPublisher {
|
|
174
|
+
constructor(private readonly producer: Producer) {
|
|
175
|
+
super();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async publish(
|
|
179
|
+
events: readonly AnyIntegrationEvent[],
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
for (const event of events) {
|
|
182
|
+
await this.producer.send({
|
|
183
|
+
messages: [{ value: JSON.stringify(event) }], // [!code --]
|
|
184
|
+
messages: [ // [!code ++]
|
|
185
|
+
{ key: event.id, value: JSON.stringify(event) }, // [!code ++]
|
|
186
|
+
], // [!code ++]
|
|
187
|
+
topic: `${event.source}.${event.type}`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This is the complete publisher.
|
|
195
|
+
|
|
196
|
+
### 5. Wire it to the relay
|
|
197
|
+
|
|
198
|
+
The composition root passes the publisher to the [`OutboxRelay`](./outbox.md#usage), the only caller of `publish`.
|
|
199
|
+
|
|
200
|
+
```ts [src/app.module.ts]
|
|
201
|
+
const relay = new OutboxRelay(
|
|
202
|
+
outbox,
|
|
203
|
+
new KafkaEventPublisher(producer),
|
|
204
|
+
100,
|
|
205
|
+
);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### 6. Check it
|
|
209
|
+
|
|
210
|
+
Run the checks. Two rules keep the publisher the way it is now:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
npx alveolus arch check
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
<div class="al-cards">
|
|
217
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-portless-adapter"><code>no-portless-adapter</code></a></span>It extends the port it implements.</div>
|
|
218
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-misplaced-class"><code>no-misplaced-class</code></a></span>It stays alone in <code>driven/kafka/adapters/*.adapter.ts</code>.</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
The same class without <code>extends EventPublisher</code> is reported:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
src/shared-kernel/driven/kafka/adapters/kafka-event-publisher.adapter.ts
|
|
225
|
+
4 error layers/no-portless-adapter: KafkaEventPublisher is a driven
|
|
226
|
+
adapter but extends no Port: extend the port it implements.
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## See also
|
|
230
|
+
|
|
231
|
+
- [Outbox](./outbox.md), whose relay calls the publisher
|
|
232
|
+
- [Integration events](./integration-events.md), what is published
|
|
233
|
+
- [Ports](../domain/ports.md), what an event publisher is
|
|
234
|
+
- Rules: [`layers/no-portless-adapter`](../../rules/layers/no-portless-adapter.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Event translators in TypeScript: turn the domain events of an aggregate into integration events, plain JSON that other bounded contexts can read."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Event translators
|
|
6
|
+
|
|
7
|
+
An event translator turns the [domain events](../domain/domain-events.md) of an aggregate into
|
|
8
|
+
[integration events](./integration-events.md): plain JSON that other bounded contexts can read.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Application</dd>
|
|
12
|
+
<dt>File</dt><dd><code>application/translators/order-events.translator.ts</code></dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>EventTranslator<Event, Output></code></a></dd>
|
|
14
|
+
<dt>Called by</dt><dd><a href="/core/application/command-handlers">Command handlers</a></dd>
|
|
15
|
+
<dt>Checked by</dt><dd><a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a>, <a href="/rules/layers/no-outward-import"><code>layers/no-outward-import</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
The billing context wants to know when an order is placed. Send it the `OrderPlaced` domain event
|
|
21
|
+
as is, and billing now depends on your classes: an `OrderId` object, a `Date`, field names you
|
|
22
|
+
chose for your own model. Rename one of them and billing breaks, without a single line of billing
|
|
23
|
+
changing.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
An event translator maps each domain event to an integration event declared in your
|
|
27
|
+
[published language](../strategic/published-language.md): JSON, with a name and a version. It is
|
|
28
|
+
the one place where your model meets the contract other contexts read, so a refactoring of the
|
|
29
|
+
domain never reaches them by accident.
|
|
30
|
+
:::
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
For each domain event it receives, the translator does three things:
|
|
35
|
+
|
|
36
|
+
<div class="al-cards">
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Recognize the event</span>Tell the events of the aggregate apart with <code>instanceof</code>.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Write the contract</span>Choose the <code>type</code>, the <code>version</code> and a JSON <code>payload</code>: identifiers and value objects become strings and numbers.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Wrap it</span><code>wrap</code> adds the id and the date of the domain event, the <code>source</code> and the correlation ids.</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
translate(
|
|
44
|
+
event: OrderPlaced,
|
|
45
|
+
context: IntegrationEventContext,
|
|
46
|
+
): OrderPlacedRepresentation {
|
|
47
|
+
return this.wrap(event, context, {
|
|
48
|
+
type: "OrderPlaced",
|
|
49
|
+
version: 1,
|
|
50
|
+
payload: {
|
|
51
|
+
orderId: event.aggregateId.value,
|
|
52
|
+
customerId: event.payload.customerId,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Where it fits
|
|
59
|
+
|
|
60
|
+
The translator runs inside the command handler, after the aggregate is saved and before its events
|
|
61
|
+
go to the outbox. Before it, events are domain objects; after it, they are JSON.
|
|
62
|
+
|
|
63
|
+
<div class="al-diagram">
|
|
64
|
+
<svg viewBox="0 0 680 130" role="img" aria-label="The Order records an OrderPlaced domain event. The PlaceOrderHandler pulls it and gives it to the OrderEventsTranslator, which returns an integration event that the handler adds to the outbox.">
|
|
65
|
+
<defs>
|
|
66
|
+
<marker id="translator-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
67
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
68
|
+
</marker>
|
|
69
|
+
</defs>
|
|
70
|
+
<rect class="box" x="8" y="36" width="130" height="56" rx="8" />
|
|
71
|
+
<text class="label" x="73" y="60" text-anchor="middle">Order</text>
|
|
72
|
+
<text class="note" x="73" y="80" text-anchor="middle">records event</text>
|
|
73
|
+
<path class="link" d="M 138 64 L 168 64" marker-end="url(#translator-flow-arrow)" />
|
|
74
|
+
<rect class="box" x="170" y="36" width="170" height="56" rx="8" />
|
|
75
|
+
<text class="label" x="255" y="60" text-anchor="middle">PlaceOrderHandler</text>
|
|
76
|
+
<text class="note" x="255" y="80" text-anchor="middle">pulls events</text>
|
|
77
|
+
<path class="link" d="M 340 64 L 370 64" marker-end="url(#translator-flow-arrow)" />
|
|
78
|
+
<rect class="boundary" x="372" y="36" width="180" height="56" rx="8" />
|
|
79
|
+
<text class="label" x="462" y="60" text-anchor="middle">OrderEventsTranslator</text>
|
|
80
|
+
<text class="note" x="462" y="80" text-anchor="middle">this page</text>
|
|
81
|
+
<path class="link" d="M 552 64 L 582 64" marker-end="url(#translator-flow-arrow)" />
|
|
82
|
+
<rect class="box" x="584" y="36" width="88" height="56" rx="8" />
|
|
83
|
+
<text class="label" x="628" y="60" text-anchor="middle">Outbox</text>
|
|
84
|
+
<text class="note" x="628" y="80" text-anchor="middle">JSON</text>
|
|
85
|
+
<text class="note" x="173" y="116" text-anchor="middle">domain events</text>
|
|
86
|
+
<text class="note" x="560" y="116" text-anchor="middle">integration events</text>
|
|
87
|
+
</svg>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
::: tip
|
|
91
|
+
The domain never knows the translator. It records events in its own words; the application decides
|
|
92
|
+
what leaves the bounded context, and in which form.
|
|
93
|
+
:::
|
|
94
|
+
|
|
95
|
+
## API
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import {
|
|
99
|
+
EventTranslator,
|
|
100
|
+
type IntegrationEventContext,
|
|
101
|
+
} from "@alveolus/core";
|
|
102
|
+
// or: from "@alveolus/core/event-translators"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### Type parameters
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
abstract class EventTranslator<
|
|
109
|
+
Event extends AnyDomainEvent,
|
|
110
|
+
Output extends AnyIntegrationEvent = AnyIntegrationEvent,
|
|
111
|
+
> { … }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
| Parameter | What it is | Constraint |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `Event` | The domain events it translates: one class, or a union. | extends `DomainEvent` |
|
|
117
|
+
| `Output` | The integration events it produces, declared in the published language. | extends `IntegrationEvent`; any by default |
|
|
118
|
+
|
|
119
|
+
### `source` <Badge type="info" text="protected · abstract · readonly" /> <Badge type="tip" text="you implement it" />
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
protected abstract readonly source: string
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The bounded context the events come from, such as `"ordering"`. `wrap` copies it into every
|
|
126
|
+
integration event.
|
|
127
|
+
|
|
128
|
+
### `translate(event, context)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by the command handler" />
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
abstract translate(
|
|
132
|
+
event: Event,
|
|
133
|
+
context: IntegrationEventContext,
|
|
134
|
+
): Output
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Turns one domain event into its integration event. The command handler calls it for each pulled
|
|
138
|
+
event, before adding the result to the outbox.
|
|
139
|
+
|
|
140
|
+
### `wrap(event, context, contract)` <Badge type="info" text="protected" /> <Badge type="tip" text="inside your methods" />
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
protected wrap<Type extends string, Payload extends JsonValue>(
|
|
144
|
+
event: Event,
|
|
145
|
+
context: IntegrationEventContext,
|
|
146
|
+
contract: {
|
|
147
|
+
readonly type: Type;
|
|
148
|
+
readonly version: number;
|
|
149
|
+
readonly payload: Payload;
|
|
150
|
+
},
|
|
151
|
+
): IntegrationEvent<Type, Payload>
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Builds the integration event from the contract: copies `id` and `occurredAt` from the domain
|
|
155
|
+
event, and adds `source`, `correlationId` and, when the context has one, `causationId`.
|
|
156
|
+
|
|
157
|
+
::: warning Caveats
|
|
158
|
+
- `occurredAt` becomes an ISO 8601 string, and `causationId` is only set when the context has one.
|
|
159
|
+
- The payload type is constrained to `JsonValue`: an `Identifier` or a `Money` does not compile.
|
|
160
|
+
- The translator lives in the application layer: it reads the domain and the published language,
|
|
161
|
+
and the domain never imports it.
|
|
162
|
+
:::
|
|
163
|
+
|
|
164
|
+
## Usage
|
|
165
|
+
|
|
166
|
+
Build the translator of the `Order` aggregate. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
|
|
167
|
+
|
|
168
|
+
<div class="al-cards">
|
|
169
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-declare-the-representation">Declare the representation</a></span>Know what you publish.</div>
|
|
170
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-translator">Declare the translator</a></span>One translator per aggregate.</div>
|
|
171
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-wrap-the-event">Wrap the event</a></span>Let wrap fill the envelope.</div>
|
|
172
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-publish-what-consumers-need">Publish what consumers need</a></span>Add the fields other contexts read.</div>
|
|
173
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-call-it-from-the-handler">Call it from the handler</a></span>Translate after saving.</div>
|
|
174
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
### 1. Declare the representation
|
|
178
|
+
|
|
179
|
+
The translator produces an [integration event](./integration-events.md) declared in the published language of the context: `OrderPlacedRepresentation`. Declare it first.
|
|
180
|
+
|
|
181
|
+
### 2. Declare the translator
|
|
182
|
+
|
|
183
|
+
The translator extends `EventTranslator` with the domain events it reads and the representations it writes. `source` names the context that emits them, once for all its events.
|
|
184
|
+
|
|
185
|
+
```ts [src/ordering/application/translators/order-events.translator.ts]
|
|
186
|
+
import { EventTranslator } from "@alveolus/core";
|
|
187
|
+
|
|
188
|
+
import type {
|
|
189
|
+
OrderPlaced,
|
|
190
|
+
} from "../../domain/events/order-placed.event";
|
|
191
|
+
import type {
|
|
192
|
+
OrderPlacedRepresentation,
|
|
193
|
+
} from "../../published-language/order-placed.representation";
|
|
194
|
+
|
|
195
|
+
export class OrderEventsTranslator extends EventTranslator<
|
|
196
|
+
OrderPlaced,
|
|
197
|
+
OrderPlacedRepresentation
|
|
198
|
+
> {
|
|
199
|
+
protected readonly source = "ordering";
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
TypeScript now asks for `translate()`: the next step adds it.
|
|
204
|
+
|
|
205
|
+
### 3. Wrap the event
|
|
206
|
+
|
|
207
|
+
So that every event carries the same envelope, `translate` gives `wrap` only the name, the version and the payload: `wrap` fills the id, the source, the date and the correlation. The payload holds plain values, never an identifier or a value object.
|
|
208
|
+
|
|
209
|
+
```ts [src/ordering/application/translators/order-events.translator.ts]
|
|
210
|
+
import { EventTranslator } from "@alveolus/core"; // [!code --]
|
|
211
|
+
import { // [!code ++]
|
|
212
|
+
EventTranslator, // [!code ++]
|
|
213
|
+
type IntegrationEventContext, // [!code ++]
|
|
214
|
+
} from "@alveolus/core"; // [!code ++]
|
|
215
|
+
|
|
216
|
+
import type {
|
|
217
|
+
OrderPlaced,
|
|
218
|
+
} from "../../domain/events/order-placed.event";
|
|
219
|
+
import type {
|
|
220
|
+
OrderPlacedRepresentation,
|
|
221
|
+
} from "../../published-language/order-placed.representation";
|
|
222
|
+
|
|
223
|
+
export class OrderEventsTranslator extends EventTranslator<
|
|
224
|
+
OrderPlaced,
|
|
225
|
+
OrderPlacedRepresentation
|
|
226
|
+
> {
|
|
227
|
+
protected readonly source = "ordering";
|
|
228
|
+
|
|
229
|
+
translate( // [!code ++]
|
|
230
|
+
event: OrderPlaced, // [!code ++]
|
|
231
|
+
context: IntegrationEventContext, // [!code ++]
|
|
232
|
+
): OrderPlacedRepresentation { // [!code ++]
|
|
233
|
+
return this.wrap(event, context, { // [!code ++]
|
|
234
|
+
type: "OrderPlaced", // [!code ++]
|
|
235
|
+
version: 1, // [!code ++]
|
|
236
|
+
payload: { // [!code ++]
|
|
237
|
+
orderId: event.aggregateId.value, // [!code ++]
|
|
238
|
+
}, // [!code ++]
|
|
239
|
+
}); // [!code ++]
|
|
240
|
+
} // [!code ++]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### 4. Publish what consumers need
|
|
245
|
+
|
|
246
|
+
A consumer cannot load the order: everything it needs must be in the payload. Add the customer, as a plain string read from the domain event.
|
|
247
|
+
|
|
248
|
+
```ts [src/ordering/application/translators/order-events.translator.ts]
|
|
249
|
+
import {
|
|
250
|
+
EventTranslator,
|
|
251
|
+
type IntegrationEventContext,
|
|
252
|
+
} from "@alveolus/core";
|
|
253
|
+
|
|
254
|
+
import type {
|
|
255
|
+
OrderPlaced,
|
|
256
|
+
} from "../../domain/events/order-placed.event";
|
|
257
|
+
import type {
|
|
258
|
+
OrderPlacedRepresentation,
|
|
259
|
+
} from "../../published-language/order-placed.representation";
|
|
260
|
+
|
|
261
|
+
export class OrderEventsTranslator extends EventTranslator<
|
|
262
|
+
OrderPlaced,
|
|
263
|
+
OrderPlacedRepresentation
|
|
264
|
+
> {
|
|
265
|
+
protected readonly source = "ordering";
|
|
266
|
+
|
|
267
|
+
translate(
|
|
268
|
+
event: OrderPlaced,
|
|
269
|
+
context: IntegrationEventContext,
|
|
270
|
+
): OrderPlacedRepresentation {
|
|
271
|
+
return this.wrap(event, context, {
|
|
272
|
+
type: "OrderPlaced",
|
|
273
|
+
version: 1,
|
|
274
|
+
payload: {
|
|
275
|
+
orderId: event.aggregateId.value,
|
|
276
|
+
customerId: event.payload.customerId, // [!code ++]
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
This is the complete translator.
|
|
284
|
+
|
|
285
|
+
### 5. Call it from the handler
|
|
286
|
+
|
|
287
|
+
The [command handler](./command-handlers.md#usage) translates each event it pulls from the order, after saving it, and adds the results to the [outbox](./outbox.md).
|
|
288
|
+
|
|
289
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
290
|
+
await this.orders.save(order);
|
|
291
|
+
const events = order
|
|
292
|
+
.pullDomainEvents()
|
|
293
|
+
.map((event) =>
|
|
294
|
+
this.translator.translate(event, {
|
|
295
|
+
correlationId: orderId,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
await this.outbox.add(events);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### 6. Check it
|
|
302
|
+
|
|
303
|
+
Run the checks. Two rules keep the translator the way it is now:
|
|
304
|
+
|
|
305
|
+
```sh
|
|
306
|
+
npx alveolus arch check
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
<div class="al-cards">
|
|
310
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-misplaced-class"><code>no-misplaced-class</code></a></span>It stays alone in <code>application/translators/*.translator.ts</code>.</div>
|
|
311
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-outward-import"><code>no-outward-import</code></a></span>It imports the domain, the application and its own published language, never an adapter.</div>
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
An import of a database adapter is reported:
|
|
315
|
+
|
|
316
|
+
```
|
|
317
|
+
src/ordering/application/translators/order-events.translator.ts
|
|
318
|
+
3 error layers/no-outward-import: The application layer imports
|
|
319
|
+
src/ordering/driven/pg/adapters/pg-orders.adapter.ts
|
|
320
|
+
(ordering driven): it may only import domain, application,
|
|
321
|
+
published-language.
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
## See also
|
|
325
|
+
|
|
326
|
+
- [Integration events](./integration-events.md), what it produces
|
|
327
|
+
- [Domain events](../domain/domain-events.md), what it reads
|
|
328
|
+
- [Published Language](../strategic/published-language.md), where the contracts are declared
|
|
329
|
+
- [Outbox](./outbox.md), where the translated events go
|