@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.
Files changed (60) hide show
  1. package/README.md +6 -0
  2. package/dist/bin.mjs +4 -2
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/{cli-P5PwH9OE.mjs → docs-DsQHpTtV.mjs} +190 -5
  5. package/dist/docs-DsQHpTtV.mjs.map +1 -0
  6. package/dist/index.d.mts +44 -2
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +2 -2
  9. package/docs/core/application/command-handlers.md +617 -0
  10. package/docs/core/application/event-publishers.md +234 -0
  11. package/docs/core/application/event-translators.md +329 -0
  12. package/docs/core/application/index.md +99 -0
  13. package/docs/core/application/integration-events.md +277 -0
  14. package/docs/core/application/outbox.md +416 -0
  15. package/docs/core/application/query-handlers.md +292 -0
  16. package/docs/core/application/unit-of-work.md +352 -0
  17. package/docs/core/domain/aggregates.md +822 -0
  18. package/docs/core/domain/domain-errors.md +251 -0
  19. package/docs/core/domain/domain-events.md +292 -0
  20. package/docs/core/domain/domain-services.md +249 -0
  21. package/docs/core/domain/entities.md +431 -0
  22. package/docs/core/domain/index.md +93 -0
  23. package/docs/core/domain/ports.md +284 -0
  24. package/docs/core/domain/repositories.md +335 -0
  25. package/docs/core/domain/value-objects.md +425 -0
  26. package/docs/core/domain/views.md +265 -0
  27. package/docs/core/index.md +108 -0
  28. package/docs/core/strategic/anti-corruption-layers.md +349 -0
  29. package/docs/core/strategic/index.md +83 -0
  30. package/docs/core/strategic/open-host-services.md +287 -0
  31. package/docs/core/strategic/published-language.md +265 -0
  32. package/docs/core/utilities/result.md +413 -0
  33. package/docs/guide/agents.md +68 -0
  34. package/docs/guide/existing-project.md +105 -0
  35. package/docs/guide/getting-started.md +275 -0
  36. package/docs/guide/learning-path.md +123 -0
  37. package/docs/guide/project-layout.md +324 -0
  38. package/docs/guide/versioning.md +42 -0
  39. package/docs/integrations/index.md +112 -0
  40. package/docs/integrations/nestjs.md +169 -0
  41. package/docs/rules/index.md +183 -0
  42. package/docs/rules/layers/no-driving-shortcut.md +119 -0
  43. package/docs/rules/layers/no-impure-domain.md +189 -0
  44. package/docs/rules/layers/no-outward-import.md +184 -0
  45. package/docs/rules/layers/no-portless-adapter.md +123 -0
  46. package/docs/rules/strategic/no-cross-context-import.md +140 -0
  47. package/docs/rules/strategic/no-fat-shared-kernel.md +81 -0
  48. package/docs/rules/strategic/no-leaky-host-service.md +107 -0
  49. package/docs/rules/strategic/no-unmapped-context.md +111 -0
  50. package/docs/rules/tactical/no-aggregate-reference.md +139 -0
  51. package/docs/rules/tactical/no-foreign-command-dependency.md +119 -0
  52. package/docs/rules/tactical/no-foreign-query-dependency.md +106 -0
  53. package/docs/rules/tactical/no-loose-code.md +171 -0
  54. package/docs/rules/tactical/no-misplaced-class.md +146 -0
  55. package/docs/rules/tactical/no-public-field.md +113 -0
  56. package/docs/rules/tactical/no-stateful-service.md +102 -0
  57. package/docs/rules/tactical/no-thrown-failure.md +162 -0
  58. package/docs/rules/tooling/no-loose-disable.md +98 -0
  59. package/package.json +4 -3
  60. package/dist/cli-P5PwH9OE.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&lt;Payload&gt;</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&lt;Id, Payload&gt;</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"