@alveolus/arch 0.1.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 +8 -1
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-CwPCGjDg.mjs → docs-DsQHpTtV.mjs} +287 -38
- package/dist/docs-DsQHpTtV.mjs.map +1 -0
- package/dist/index.d.mts +90 -36
- 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-CwPCGjDg.mjs.map +0 -1
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Domain errors in TypeScript: expected business failures returned as values in a Result instead of thrown exceptions, visible in every signature."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Domain errors
|
|
6
|
+
|
|
7
|
+
A domain error is an expected business failure, such as an order placed twice, returned as a value
|
|
8
|
+
instead of thrown.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Domain</dd>
|
|
12
|
+
<dt>File</dt><dd><code>domain/errors/order-already-placed.error.ts</code></dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>DomainError<Payload></code></a></dd>
|
|
14
|
+
<dt>Returned by</dt><dd><a href="/core/domain/aggregates">Aggregates</a>, <a href="/core/domain/entities">entities</a>, <a href="/core/domain/value-objects">value objects</a>, <a href="/core/application/command-handlers">command handlers</a></dd>
|
|
15
|
+
<dt>Checked by</dt><dd><a href="/rules/tactical/no-thrown-failure"><code>tactical/no-thrown-failure</code></a>, <a href="/rules/tactical/no-loose-code"><code>tactical/no-loose-code</code></a>, <a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`Order.place` throws when the order is empty. Nothing in its signature says so. The handler forgets
|
|
21
|
+
a `try`, the controller too, and a customer who clicks "Place" on an empty cart gets a 500 instead
|
|
22
|
+
of a clear message.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
`place` returns `Result<void, OrderAlreadyPlaced | EmptyOrder>`. Every caller sees in the signature
|
|
26
|
+
what can go wrong, and the compiler makes it handle the failure before it reads the value.
|
|
27
|
+
:::
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
A domain error is a small class, not an `Error`: no stack trace, never thrown. It travels inside a
|
|
32
|
+
[`Result`](../utilities/result.md), from the method that refuses to the edge that answers.
|
|
33
|
+
|
|
34
|
+
<div class="al-cards">
|
|
35
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Declare it</span>One class per way the rules can refuse an operation, with the data that explains it.</div>
|
|
36
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Return it</span>The business method returns <code>err(new EmptyOrder())</code>. Nothing changes.</div>
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Answer it at the edge</span>The handler passes it through unchanged; the controller turns it into a response.</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
if (this.lines.length === 0) {
|
|
42
|
+
return err(new EmptyOrder());
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Where it fits
|
|
47
|
+
|
|
48
|
+
<div class="al-diagram">
|
|
49
|
+
<svg viewBox="0 0 680 120" role="img" aria-label="Order.place returns an EmptyOrder error. The command handler returns it unchanged, and the controller turns it into an HTTP 422 response.">
|
|
50
|
+
<defs>
|
|
51
|
+
<marker id="domain-error-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
52
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
53
|
+
</marker>
|
|
54
|
+
</defs>
|
|
55
|
+
<rect class="boundary" x="8" y="32" width="200" height="56" rx="8" />
|
|
56
|
+
<text class="label" x="108" y="56" text-anchor="middle">Order.place()</text>
|
|
57
|
+
<text class="note" x="108" y="76" text-anchor="middle">err(new EmptyOrder())</text>
|
|
58
|
+
<path class="link" d="M 208 60 L 238 60" marker-end="url(#domain-error-flow-arrow)" />
|
|
59
|
+
<rect class="box" x="240" y="32" width="200" height="56" rx="8" />
|
|
60
|
+
<text class="label" x="340" y="56" text-anchor="middle">PlaceOrderHandler</text>
|
|
61
|
+
<text class="note" x="340" y="76" text-anchor="middle">returns it unchanged</text>
|
|
62
|
+
<path class="link" d="M 440 60 L 470 60" marker-end="url(#domain-error-flow-arrow)" />
|
|
63
|
+
<rect class="box" x="472" y="32" width="200" height="56" rx="8" />
|
|
64
|
+
<text class="label" x="572" y="56" text-anchor="middle">Controller</text>
|
|
65
|
+
<text class="note" x="572" y="76" text-anchor="middle">422 { error: "EmptyOrder" }</text>
|
|
66
|
+
</svg>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
::: tip
|
|
70
|
+
Domain errors become HTTP errors in the driving adapter, and nowhere else. The domain and the
|
|
71
|
+
application never know about status codes.
|
|
72
|
+
:::
|
|
73
|
+
|
|
74
|
+
## API
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { DomainError } from "@alveolus/core";
|
|
78
|
+
// or: import { DomainError } from "@alveolus/core/domain-errors";
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Type parameters
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
abstract class DomainError<Payload = undefined> { … }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Parameter | What it is | Constraint |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `Payload` | The data describing the failure. | any; `undefined` by default: no data |
|
|
90
|
+
|
|
91
|
+
`AnyDomainError` is the type of any domain error; handlers only accept errors of this type.
|
|
92
|
+
|
|
93
|
+
### `constructor(payload)` <Badge type="tip" text="you call it" />
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
constructor(
|
|
97
|
+
...[payload]: Payload extends undefined
|
|
98
|
+
? []
|
|
99
|
+
: [payload: Payload]
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Creates the error. Without a `Payload`, it takes no argument: `new EmptyOrder()`. With one, the
|
|
104
|
+
payload is required: `new InvalidQuantity({ quantity })`. An error has no body: declare the class
|
|
105
|
+
only.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
class EmptyOrder extends DomainError {}
|
|
109
|
+
class InvalidQuantity extends DomainError<{ readonly quantity: number }> {}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### `payload` <Badge type="info" text="readonly" /> <Badge type="tip" text="read at the edge" />
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
readonly payload: Payload
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The data of the failure, `undefined` for an error without data.
|
|
119
|
+
|
|
120
|
+
### `type` <Badge type="info" text="getter" /> <Badge type="tip" text="read at the edge" />
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
get type(): string
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The class name, such as `"EmptyOrder"`: what a driving adapter puts in its response.
|
|
127
|
+
|
|
128
|
+
::: warning Caveats
|
|
129
|
+
- `type` is the class name: a bundler that minifies class names changes it. Keep class names in
|
|
130
|
+
your build (`keep_classnames` in Terser, `keepNames` in esbuild), or narrow with `instanceof`.
|
|
131
|
+
- A `DomainError` does not extend `Error`. In the domain and the application, `class X extends
|
|
132
|
+
Error` is reported by [`tactical/no-loose-code`](../../rules/tactical/no-loose-code.md), and
|
|
133
|
+
nothing is thrown there ([`tactical/no-thrown-failure`](../../rules/tactical/no-thrown-failure.md)):
|
|
134
|
+
only adapters throw, for technical failures.
|
|
135
|
+
:::
|
|
136
|
+
|
|
137
|
+
## Usage
|
|
138
|
+
|
|
139
|
+
Build `InvalidQuantity`, a failure of the `Order` aggregate, from its class to the response it
|
|
140
|
+
becomes. Each step shows the whole file it changes: added lines are highlighted, replaced lines are struck out.
|
|
141
|
+
|
|
142
|
+
<div class="al-cards">
|
|
143
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-declare-the-failure">Declare the failure</a></span>One class per failure.</div>
|
|
144
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-carry-what-the-caller-needs">Carry what the caller needs</a></span>A typed payload.</div>
|
|
145
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-return-it-in-a-result">Return it in a Result</a></span>Never thrown.</div>
|
|
146
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-turn-it-into-a-response">Turn it into a response</a></span>At the edge only.</div>
|
|
147
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
### 1. Declare the failure
|
|
151
|
+
|
|
152
|
+
So that a caller can tell this failure from any other, it is a class of its own, named after what
|
|
153
|
+
went wrong, in its own file.
|
|
154
|
+
|
|
155
|
+
```ts [src/ordering/domain/errors/invalid-quantity.error.ts]
|
|
156
|
+
import { DomainError } from "@alveolus/core";
|
|
157
|
+
|
|
158
|
+
export class InvalidQuantity extends DomainError {}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Without a type parameter, the error carries no data: `new InvalidQuantity()` takes no argument.
|
|
162
|
+
|
|
163
|
+
### 2. Carry what the caller needs
|
|
164
|
+
|
|
165
|
+
The caller must be able to explain the failure: the payload holds the data, read-only, and becomes
|
|
166
|
+
required by the constructor.
|
|
167
|
+
|
|
168
|
+
```ts [src/ordering/domain/errors/invalid-quantity.error.ts]
|
|
169
|
+
import { DomainError } from "@alveolus/core";
|
|
170
|
+
|
|
171
|
+
export class InvalidQuantity extends DomainError {} // [!code --]
|
|
172
|
+
export class InvalidQuantity extends DomainError<{ // [!code ++]
|
|
173
|
+
readonly quantity: number; // [!code ++]
|
|
174
|
+
}> {} // [!code ++]
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A failure with nothing to explain keeps no payload:
|
|
178
|
+
|
|
179
|
+
```ts [src/ordering/domain/errors/empty-order.error.ts]
|
|
180
|
+
import { DomainError } from "@alveolus/core";
|
|
181
|
+
|
|
182
|
+
export class EmptyOrder extends DomainError {}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
### 3. Return it in a Result
|
|
186
|
+
|
|
187
|
+
So that every caller sees the failure in the signature, the domain returns the error in a
|
|
188
|
+
[`Result`](../utilities/result.md), and never throws it. Nothing changes when it is returned.
|
|
189
|
+
|
|
190
|
+
```ts [src/ordering/domain/entities/order-line.entity.ts]
|
|
191
|
+
changeQuantity(quantity: number): Result<void, InvalidQuantity> {
|
|
192
|
+
if (quantity <= 0) {
|
|
193
|
+
return err(new InvalidQuantity({ quantity }));
|
|
194
|
+
}
|
|
195
|
+
this.currentQuantity = quantity;
|
|
196
|
+
return ok();
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 4. Turn it into a response
|
|
201
|
+
|
|
202
|
+
Only the edge knows about HTTP: the driving adapter narrows the union with `instanceof`, reads
|
|
203
|
+
`type` and `payload`, and chooses the status.
|
|
204
|
+
|
|
205
|
+
```ts [src/ordering/driving/http/controllers/orders.controller.ts]
|
|
206
|
+
if (!placed.ok) {
|
|
207
|
+
const body = {
|
|
208
|
+
error: placed.error.type,
|
|
209
|
+
details: placed.error.payload,
|
|
210
|
+
};
|
|
211
|
+
if (placed.error instanceof OrderNotFound) {
|
|
212
|
+
return { status: 404, body };
|
|
213
|
+
}
|
|
214
|
+
return { status: 422, body };
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 5. Check it
|
|
219
|
+
|
|
220
|
+
Run the checks. Three rules keep the error the way it is now:
|
|
221
|
+
|
|
222
|
+
```sh
|
|
223
|
+
npx alveolus arch check
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
<div class="al-cards">
|
|
227
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-thrown-failure"><code>no-thrown-failure</code></a></span>Nothing throws a <code>DomainError</code>: it is returned in a <code>Result</code>.</div>
|
|
228
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-loose-code"><code>no-loose-code</code></a></span>It extends <code>DomainError</code>, not <code>Error</code>.</div>
|
|
229
|
+
<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>domain/errors/*.error.ts</code>.</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
A thrown error is reported:
|
|
233
|
+
|
|
234
|
+
```
|
|
235
|
+
src/ordering/domain/entities/order-line.entity.ts
|
|
236
|
+
41 error tactical/no-thrown-failure: A failure is thrown: return it in a
|
|
237
|
+
Result instead.
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Troubleshooting
|
|
241
|
+
|
|
242
|
+
**`Expected 0 arguments, but got 1`** or **`Expected 1 arguments, but got 0`**: the arguments do
|
|
243
|
+
not match the payload type. An error declared without a type parameter takes no argument; one
|
|
244
|
+
declared with a payload requires it.
|
|
245
|
+
|
|
246
|
+
## See also
|
|
247
|
+
|
|
248
|
+
- [Result](../utilities/result.md), how errors are returned and combined
|
|
249
|
+
- [Aggregates](./aggregates.md), [Entities](./entities.md) and [Value objects](./value-objects.md), which return them
|
|
250
|
+
- [Command handlers](../application/command-handlers.md), which declare them
|
|
251
|
+
- Rules: [`tactical/no-thrown-failure`](../../rules/tactical/no-thrown-failure.md), [`tactical/no-loose-code`](../../rules/tactical/no-loose-code.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Domain events in Domain-Driven Design with TypeScript: record what happened in the domain, named in the past tense, such as OrderPlaced."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Domain events
|
|
6
|
+
|
|
7
|
+
A domain event records something that happened in the domain, named in the past tense, such as
|
|
8
|
+
`OrderPlaced`.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Domain</dd>
|
|
12
|
+
<dt>File</dt><dd><code>domain/events/order-placed.event.ts</code></dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>DomainEvent<Id, Payload></code></a></dd>
|
|
14
|
+
<dt>Recorded by</dt><dd><a href="/core/domain/aggregates">Aggregates</a></dd>
|
|
15
|
+
<dt>Read by</dt><dd><a href="/core/application/command-handlers">Command handlers</a>, <a href="/core/application/event-translators">event translators</a></dd>
|
|
16
|
+
<dt>Checked by</dt><dd><a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a>, <a href="/rules/tactical/no-aggregate-reference"><code>tactical/no-aggregate-reference</code></a></dd>
|
|
17
|
+
</dl>
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
When an order is placed, billing must invoice it and the customer must get an email. If
|
|
22
|
+
`Order.place` calls billing and the mailer, the domain depends on them, and an order cannot be placed
|
|
23
|
+
while the mail server is down. If the handler compares the order before and after to guess what
|
|
24
|
+
changed, the rule is written twice.
|
|
25
|
+
|
|
26
|
+
::: tip The fix
|
|
27
|
+
`Order.place` records `OrderPlaced`: a plain fact, with what others need to know. The order does
|
|
28
|
+
not know who reacts. The application hands the fact over after saving, and each listener decides
|
|
29
|
+
what to do with it.
|
|
30
|
+
:::
|
|
31
|
+
|
|
32
|
+
## How it works
|
|
33
|
+
|
|
34
|
+
An event is a small immutable object: an id, the identifier of the aggregate that recorded it, the
|
|
35
|
+
date, and a payload. It goes through three hands:
|
|
36
|
+
|
|
37
|
+
<div class="al-cards">
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>The aggregate records it</span>Its business method calls <code>this.record(event)</code> after changing the state.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>The handler pulls it</span>After saving, the <a href="/core/application/command-handlers">command handler</a> calls <code>pullDomainEvents()</code>.</div>
|
|
40
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>A translator sends it on</span>An <a href="/core/application/event-translators">event translator</a> turns it into JSON, added to the <a href="/core/application/outbox">outbox</a>.</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
this.record(
|
|
45
|
+
new OrderPlaced({
|
|
46
|
+
id: eventId,
|
|
47
|
+
aggregateId: this.id,
|
|
48
|
+
occurredAt: now,
|
|
49
|
+
payload: { customerId: this.customerId.value },
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Where it fits
|
|
55
|
+
|
|
56
|
+
<div class="al-diagram">
|
|
57
|
+
<svg viewBox="0 0 680 120" role="img" aria-label="Order.place records OrderPlaced. The command handler pulls it after saving, an event translator turns it into an integration event, and the outbox stores it.">
|
|
58
|
+
<defs>
|
|
59
|
+
<marker id="domain-event-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
60
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
61
|
+
</marker>
|
|
62
|
+
</defs>
|
|
63
|
+
<rect class="box" x="8" y="32" width="148" height="56" rx="8" />
|
|
64
|
+
<text class="label" x="82" y="56" text-anchor="middle">Order</text>
|
|
65
|
+
<text class="note" x="82" y="76" text-anchor="middle">place() records</text>
|
|
66
|
+
<path class="link" d="M 156 60 L 178 60" marker-end="url(#domain-event-flow-arrow)" />
|
|
67
|
+
<rect class="boundary" x="180" y="32" width="148" height="56" rx="8" />
|
|
68
|
+
<text class="label" x="254" y="56" text-anchor="middle">OrderPlaced</text>
|
|
69
|
+
<text class="note" x="254" y="76" text-anchor="middle">this page</text>
|
|
70
|
+
<path class="link" d="M 328 60 L 350 60" marker-end="url(#domain-event-flow-arrow)" />
|
|
71
|
+
<rect class="box" x="352" y="32" width="148" height="56" rx="8" />
|
|
72
|
+
<text class="label" x="426" y="56" text-anchor="middle">Translator</text>
|
|
73
|
+
<text class="note" x="426" y="76" text-anchor="middle">translate(event)</text>
|
|
74
|
+
<path class="link" d="M 500 60 L 522 60" marker-end="url(#domain-event-flow-arrow)" />
|
|
75
|
+
<rect class="box" x="524" y="32" width="148" height="56" rx="8" />
|
|
76
|
+
<text class="label" x="598" y="56" text-anchor="middle">Outbox</text>
|
|
77
|
+
<text class="note" x="598" y="76" text-anchor="middle">add(events)</text>
|
|
78
|
+
</svg>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
::: tip
|
|
82
|
+
A domain event never leaves its bounded context. Other contexts receive an
|
|
83
|
+
[integration event](../application/integration-events.md), plain JSON: renaming a domain event or
|
|
84
|
+
one of its fields then changes nothing for them.
|
|
85
|
+
:::
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { DomainEvent } from "@alveolus/core";
|
|
91
|
+
// or: import { DomainEvent } from "@alveolus/core/domain-events";
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Type parameters
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
abstract class DomainEvent<
|
|
98
|
+
Id extends AnyIdentifier = AnyIdentifier,
|
|
99
|
+
Payload = unknown,
|
|
100
|
+
> { … }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
| Parameter | What it is | Constraint |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| `Id` | The identifier of the aggregate that records the event. | extends `Identifier`; any by default |
|
|
106
|
+
| `Payload` | The data of the event. `null` for an event without data. | any; `unknown` by default |
|
|
107
|
+
|
|
108
|
+
`AnyDomainEvent` is the type of any domain event.
|
|
109
|
+
|
|
110
|
+
### `constructor(props)` <Badge type="tip" text="you call it" />
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
constructor(props: DomainEventProps<Id, Payload>)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Creates the event, inside a method of the aggregate. `props` holds `id`, from the `IdGenerator`
|
|
117
|
+
port, `aggregateId`, `occurredAt`, from the `Clock` port, and `payload`. `occurredAt` is copied.
|
|
118
|
+
An event has no body: declare the class only.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
class OrderPlaced extends DomainEvent<
|
|
122
|
+
OrderId,
|
|
123
|
+
{ readonly customerId: string }
|
|
124
|
+
> {}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### `id` <Badge type="info" text="readonly" /> <Badge type="tip" text="read by translators and consumers" />
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
readonly id: string
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The unique id of the event, used downstream to ignore duplicates.
|
|
134
|
+
|
|
135
|
+
### `aggregateId` <Badge type="info" text="readonly" /> <Badge type="tip" text="read by translators" />
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
readonly aggregateId: Id
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The identifier of the aggregate that recorded the event.
|
|
142
|
+
|
|
143
|
+
### `occurredAt` <Badge type="info" text="readonly" /> <Badge type="tip" text="read by translators" />
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
readonly occurredAt: Date
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
When it happened.
|
|
150
|
+
|
|
151
|
+
### `payload` <Badge type="info" text="readonly" /> <Badge type="tip" text="read by translators" />
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
readonly payload: Payload
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The data of the event.
|
|
158
|
+
|
|
159
|
+
::: warning Caveats
|
|
160
|
+
- `occurredAt` is copied: changing the date you passed does not change the event.
|
|
161
|
+
- The payload is not frozen and may hold value objects and identifiers: it is internal to the
|
|
162
|
+
context. Keep it read-only by convention.
|
|
163
|
+
- An event has no `type` string: tell events apart with `instanceof`.
|
|
164
|
+
:::
|
|
165
|
+
|
|
166
|
+
## Usage
|
|
167
|
+
|
|
168
|
+
Build `OrderPlaced`, the event the `Order` aggregate records when it is placed, then follow it out
|
|
169
|
+
of the aggregate. Each step shows the whole file it changes: added lines are highlighted, replaced lines are struck out.
|
|
170
|
+
|
|
171
|
+
<div class="al-cards">
|
|
172
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-know-who-records-it">Know who records it</a></span>The aggregate it belongs to.</div>
|
|
173
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-event">Declare the event</a></span>One class per fact.</div>
|
|
174
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-carry-what-consumers-need">Carry what consumers need</a></span>A read-only payload.</div>
|
|
175
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-record-it-in-the-aggregate">Record it in the aggregate</a></span>Where the change happens.</div>
|
|
176
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-hand-it-over-after-saving">Hand it over after saving</a></span>Pulled, translated, stored.</div>
|
|
177
|
+
<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>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
### 1. Know who records it
|
|
181
|
+
|
|
182
|
+
An event belongs to the aggregate that records it: here the [`Order` aggregate](./aggregates.md),
|
|
183
|
+
known by its `OrderId`.
|
|
184
|
+
|
|
185
|
+
### 2. Declare the event
|
|
186
|
+
|
|
187
|
+
So that the rest of the system can react to a fact by its type, each event is a class named in the
|
|
188
|
+
past tense, in its own file. `null` says it carries no data yet.
|
|
189
|
+
|
|
190
|
+
```ts [src/ordering/domain/events/order-placed.event.ts]
|
|
191
|
+
import { DomainEvent } from "@alveolus/core";
|
|
192
|
+
|
|
193
|
+
import type { OrderId } from "../value-objects/order-id.identifier";
|
|
194
|
+
|
|
195
|
+
export class OrderPlaced extends DomainEvent<OrderId, null> {}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 3. Carry what consumers need
|
|
199
|
+
|
|
200
|
+
Consumers must not have to load the order again: the payload carries the data they need,
|
|
201
|
+
read-only.
|
|
202
|
+
|
|
203
|
+
```ts [src/ordering/domain/events/order-placed.event.ts]
|
|
204
|
+
import { DomainEvent } from "@alveolus/core";
|
|
205
|
+
|
|
206
|
+
import type { OrderId } from "../value-objects/order-id.identifier";
|
|
207
|
+
|
|
208
|
+
export class OrderPlaced extends DomainEvent<OrderId, null> {} // [!code --]
|
|
209
|
+
export class OrderPlaced extends DomainEvent< // [!code ++]
|
|
210
|
+
OrderId, // [!code ++]
|
|
211
|
+
{ readonly customerId: string } // [!code ++]
|
|
212
|
+
> {} // [!code ++]
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The payload stays inside the context: an [event translator](../application/event-translators.md)
|
|
216
|
+
turns it into the published language before it leaves.
|
|
217
|
+
|
|
218
|
+
### 4. Record it in the aggregate
|
|
219
|
+
|
|
220
|
+
The event is created where the change happens, in the business method, with the event id and the
|
|
221
|
+
date passed in: the aggregate never reads a clock nor generates an id.
|
|
222
|
+
|
|
223
|
+
```ts [src/ordering/domain/aggregates/order.aggregate.ts]
|
|
224
|
+
place(
|
|
225
|
+
eventId: string,
|
|
226
|
+
now: Date,
|
|
227
|
+
): Result<void, OrderAlreadyPlaced | EmptyOrder> {
|
|
228
|
+
if (this.isPlaced) {
|
|
229
|
+
return err(new OrderAlreadyPlaced());
|
|
230
|
+
}
|
|
231
|
+
if (this.lines.length === 0) {
|
|
232
|
+
return err(new EmptyOrder());
|
|
233
|
+
}
|
|
234
|
+
this.status = "placed";
|
|
235
|
+
this.record(
|
|
236
|
+
new OrderPlaced({
|
|
237
|
+
id: eventId,
|
|
238
|
+
aggregateId: this.id,
|
|
239
|
+
occurredAt: now,
|
|
240
|
+
payload: { customerId: this.customerId.value },
|
|
241
|
+
}),
|
|
242
|
+
);
|
|
243
|
+
return ok();
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### 5. Hand it over after saving
|
|
248
|
+
|
|
249
|
+
So that no event leaves for a change that was not saved, the
|
|
250
|
+
[command handler](../application/command-handlers.md) pulls the events after `save` and adds them
|
|
251
|
+
to the [outbox](../application/outbox.md), in the same unit of work.
|
|
252
|
+
|
|
253
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
254
|
+
await this.orders.save(order);
|
|
255
|
+
const events = order
|
|
256
|
+
.pullDomainEvents()
|
|
257
|
+
.map((event) =>
|
|
258
|
+
this.translator.translate(event, { correlationId }),
|
|
259
|
+
);
|
|
260
|
+
await this.outbox.add(events);
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
### 6. Check it
|
|
264
|
+
|
|
265
|
+
Run the checks. Three rules keep the event the way it is now:
|
|
266
|
+
|
|
267
|
+
```sh
|
|
268
|
+
npx alveolus arch check
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
<div class="al-cards">
|
|
272
|
+
<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>domain/events/*.event.ts</code>.</div>
|
|
273
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-impure-domain"><code>no-impure-domain</code></a></span>Its payload uses domain types and plain data, no framework.</div>
|
|
274
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-loose-code"><code>no-loose-code</code></a></span>The fact is a <code>DomainEvent</code>, not a plain class.</div>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
An event declared next to its aggregate is reported:
|
|
278
|
+
|
|
279
|
+
```
|
|
280
|
+
src/ordering/domain/aggregates/order.aggregate.ts
|
|
281
|
+
12 error tactical/no-misplaced-class: OrderPlaced belongs in
|
|
282
|
+
domain/events/*.event.ts.
|
|
283
|
+
```
|
|
284
|
+
|
|
285
|
+
## See also
|
|
286
|
+
|
|
287
|
+
- [Aggregates](./aggregates.md), which record events
|
|
288
|
+
- [Event translators](../application/event-translators.md) and [Integration events](../application/integration-events.md), to publish them
|
|
289
|
+
- [Outbox](../application/outbox.md), so none is lost
|
|
290
|
+
- Rules: [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
|
|
291
|
+
- Vaughn Vernon, *Domain-Driven Design Distilled*, chapter 6, "Tactical Design with Domain Events"
|
|
292
|
+
- Vaughn Vernon, *Implementing Domain-Driven Design*, chapter 8, "Domain Events"
|