@alveolus/arch 0.2.0 → 0.4.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 +11 -0
  2. package/dist/bin.mjs +4 -2
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/{cli-P5PwH9OE.mjs → docs-DcFgskuN.mjs} +214 -43
  5. package/dist/docs-DcFgskuN.mjs.map +1 -0
  6. package/dist/index.d.mts +53 -12
  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 +108 -0
  35. package/docs/guide/getting-started.md +286 -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 +185 -0
  42. package/docs/rules/layers/no-driving-shortcut.md +119 -0
  43. package/docs/rules/layers/no-impure-domain.md +191 -0
  44. package/docs/rules/layers/no-outward-import.md +186 -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 +114 -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 +201 -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,822 @@
1
+ ---
2
+ description: "Aggregates in Domain-Driven Design with TypeScript: a group of objects changed together through one aggregate root that keeps their invariants."
3
+ ---
4
+
5
+ # Aggregates
6
+
7
+ An aggregate is a group of objects changed together through one entry point, the root, which keeps
8
+ their rules.
9
+
10
+ <dl class="al-glance">
11
+ <dt>Layer</dt><dd>Domain</dd>
12
+ <dt>File</dt><dd><code>domain/aggregates/order.aggregate.ts</code></dd>
13
+ <dt>Extends</dt><dd><a href="#api"><code>AggregateRoot&lt;Id, Event, Snapshot&gt;</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/tactical/no-thrown-failure"><code>tactical/no-thrown-failure</code></a>, <a href="/rules/tactical/no-aggregate-reference"><code>tactical/no-aggregate-reference</code></a>, <a href="/rules/tactical/no-public-field"><code>tactical/no-public-field</code></a></dd>
16
+ </dl>
17
+
18
+ ## Why
19
+
20
+ An order must not be placed empty, and once placed, its lines must not change. If any code can add
21
+ a line or flip the status, sooner or later one of them forgets a rule, and nothing tells you.
22
+
23
+ ::: tip The fix
24
+ An aggregate puts the order and its lines behind one door: the root, `Order`. Every change goes
25
+ through one of its methods, which checks the rules, applies the change and records what happened.
26
+ It is loaded, changed and saved as a whole.
27
+ :::
28
+
29
+ ## How it works
30
+
31
+ The aggregate is a boundary drawn around the objects that share rules. Inside, one object is the
32
+ root: the only one code outside may hold and call. The others, such as the
33
+ [entities](./entities.md) `OrderLine`, are reached through it. Another aggregate, such as
34
+ `Customer`, stays outside: the order keeps only its identifier.
35
+
36
+ <div class="al-diagram">
37
+ <svg viewBox="0 0 680 250" role="img" aria-label="The Order aggregate: the root Order holds its order lines. It refers to the Customer aggregate only through a CustomerId.">
38
+ <defs>
39
+ <marker id="aggregate-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
40
+ <path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
41
+ </marker>
42
+ </defs>
43
+ <rect class="boundary" x="8" y="8" width="392" height="234" rx="14" />
44
+ <text class="note" x="24" y="32">Order aggregate · one transaction</text>
45
+ <rect class="box" x="124" y="52" width="160" height="56" rx="8" />
46
+ <text class="label" x="204" y="76" text-anchor="middle">Order</text>
47
+ <text class="note" x="204" y="96" text-anchor="middle">root · the only door</text>
48
+ <rect class="box" x="30" y="166" width="160" height="56" rx="8" />
49
+ <text class="label" x="110" y="190" text-anchor="middle">OrderLine</text>
50
+ <text class="note" x="110" y="210" text-anchor="middle">Entity</text>
51
+ <rect class="box" x="218" y="166" width="160" height="56" rx="8" />
52
+ <text class="label" x="298" y="190" text-anchor="middle">OrderLine</text>
53
+ <text class="note" x="298" y="210" text-anchor="middle">Entity</text>
54
+ <path class="link" d="M 176 108 L 122 164" marker-end="url(#aggregate-arrow)" />
55
+ <path class="link" d="M 232 108 L 286 164" marker-end="url(#aggregate-arrow)" />
56
+ <rect class="box" x="506" y="52" width="160" height="56" rx="8" />
57
+ <text class="label" x="586" y="76" text-anchor="middle">Customer</text>
58
+ <text class="note" x="586" y="96" text-anchor="middle">another aggregate</text>
59
+ <path class="link" d="M 284 80 L 504 80" stroke-dasharray="4 4" marker-end="url(#aggregate-arrow)" />
60
+ <text class="note" x="453" y="70" text-anchor="middle">CustomerId</text>
61
+ </svg>
62
+ </div>
63
+
64
+ A business method of the root does three things, in this order:
65
+
66
+ <div class="al-cards">
67
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Check the rules</span>If one is broken, return a <a href="./domain-errors">domain error</a> in a <a href="../utilities/result"><code>Result</code></a>. Nothing changes.</div>
68
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Change the state</span>Fields are private: only the aggregate writes them.</div>
69
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Record an event</span>A <a href="./domain-events">domain event</a> says what happened, for the rest of the system.</div>
70
+ </div>
71
+
72
+ ```ts
73
+ place(
74
+ eventId: string,
75
+ now: Date,
76
+ ): Result<void, OrderAlreadyPlaced | EmptyOrder> {
77
+ if (this.status === "placed") {
78
+ return err(new OrderAlreadyPlaced());
79
+ }
80
+ if (this.lines.length === 0) {
81
+ return err(new EmptyOrder());
82
+ }
83
+ this.status = "placed";
84
+ this.record(
85
+ new OrderPlaced({
86
+ id: eventId,
87
+ aggregateId: this.id,
88
+ occurredAt: now,
89
+ payload: { customerId: this.customerId.value },
90
+ }),
91
+ );
92
+ return ok();
93
+ }
94
+ ```
95
+
96
+ ## Where it fits
97
+
98
+ The aggregate never runs alone. A [command handler](../application/command-handlers.md) loads it
99
+ through a [repository](./repositories.md), calls one business method, saves it, and hands its events
100
+ to the [outbox](../application/outbox.md), all in one [unit of work](../application/unit-of-work.md).
101
+
102
+ <div class="al-diagram">
103
+ <svg viewBox="0 0 680 300" role="img" aria-label="A request goes from a controller to the PlaceOrderHandler, which in one unit of work loads the Order from the repository, calls order.place, saves the order and adds its events to the outbox.">
104
+ <defs>
105
+ <marker id="flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
106
+ <path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
107
+ </marker>
108
+ </defs>
109
+ <rect class="box" x="8" y="122" width="130" height="56" rx="8" />
110
+ <text class="label" x="73" y="146" text-anchor="middle">Controller</text>
111
+ <text class="note" x="73" y="166" text-anchor="middle">driving adapter</text>
112
+ <path class="link" d="M 138 150 L 178 150" marker-end="url(#flow-arrow)" />
113
+ <rect class="box" x="180" y="122" width="180" height="56" rx="8" />
114
+ <text class="label" x="270" y="146" text-anchor="middle">PlaceOrderHandler</text>
115
+ <text class="note" x="270" y="166" text-anchor="middle">command handler</text>
116
+ <text class="note" x="270" y="204" text-anchor="middle">one unit of work</text>
117
+ <rect class="box" x="440" y="24" width="232" height="48" rx="8" />
118
+ <text class="label" x="556" y="44" text-anchor="middle">1 · orders.findById(id)</text>
119
+ <text class="note" x="556" y="62" text-anchor="middle">loads the Order</text>
120
+ <rect class="boundary" x="440" y="92" width="232" height="48" rx="8" />
121
+ <text class="label" x="556" y="112" text-anchor="middle">2 · order.place(…)</text>
122
+ <text class="note" x="556" y="130" text-anchor="middle">this page: rules + event</text>
123
+ <rect class="box" x="440" y="160" width="232" height="48" rx="8" />
124
+ <text class="label" x="556" y="180" text-anchor="middle">3 · orders.save(order)</text>
125
+ <text class="note" x="556" y="198" text-anchor="middle">stores its snapshot</text>
126
+ <rect class="box" x="440" y="228" width="232" height="48" rx="8" />
127
+ <text class="label" x="556" y="248" text-anchor="middle">4 · outbox.add(events)</text>
128
+ <text class="note" x="556" y="266" text-anchor="middle">hands over its events</text>
129
+ <path class="link" d="M 360 150 L 438 48" marker-end="url(#flow-arrow)" />
130
+ <path class="link" d="M 360 150 L 438 116" marker-end="url(#flow-arrow)" />
131
+ <path class="link" d="M 360 150 L 438 184" marker-end="url(#flow-arrow)" />
132
+ <path class="link" d="M 360 150 L 438 252" marker-end="url(#flow-arrow)" />
133
+ </svg>
134
+ </div>
135
+
136
+ ::: tip
137
+ The handler decides nothing: it coordinates. Every business rule lives in the aggregate, so the same
138
+ rule holds whichever handler, test or script calls it.
139
+ :::
140
+
141
+ ## API
142
+
143
+ ```ts
144
+ import { AggregateRoot } from "@alveolus/core";
145
+ // or: import { AggregateRoot } from "@alveolus/core/aggregates";
146
+ ```
147
+
148
+ ### Type parameters
149
+
150
+ ```ts
151
+ abstract class AggregateRoot<
152
+ Id extends AnyIdentifier,
153
+ Event extends AnyDomainEvent = AnyDomainEvent,
154
+ Snapshot extends AnySnapshot = AnySnapshot,
155
+ > extends Entity<Id, Snapshot> { … }
156
+ ```
157
+
158
+ | Parameter | What it is | Constraint |
159
+ | --- | --- | --- |
160
+ | `Id` | The [identifier](./value-objects.md#identifier) of the aggregate. | extends `Identifier` |
161
+ | `Event` | The domain events it records: one class, or a union such as `OrderPlaced \| OrderCancelled`. | extends `DomainEvent`; any event by default |
162
+ | `Snapshot` | The plain data its state is saved as. | a `type` of plain data; any by default |
163
+
164
+ `AnyAggregateRoot` is the type of any aggregate, for code that accepts all of them.
165
+
166
+ ### `constructor(id)` <Badge type="info" text="protected" /> <Badge type="tip" text="you call it" />
167
+
168
+ ```ts
169
+ protected constructor(id: Id)
170
+ ```
171
+
172
+ Stores the identifier. Declare your own constructor `private` and call `super(id)` from it: only
173
+ your factories and `fromSnapshot` create the aggregate.
174
+
175
+ ### `toSnapshot()` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" />
176
+
177
+ ```ts
178
+ abstract toSnapshot(): Snapshot
179
+ ```
180
+
181
+ Returns the state as plain data, for the repository. Each entity inside is written as its own
182
+ snapshot, each value object as its raw value.
183
+
184
+ ### `fromSnapshot(snapshot)` <Badge type="info" text="static · convention" /> <Badge type="tip" text="you implement it" />
185
+
186
+ ```ts
187
+ static fromSnapshot(snapshot: OrderSnapshot): Order
188
+ ```
189
+
190
+ Rebuilds the aggregate from its snapshot, through the private constructor. It checks no rule and
191
+ records no event. Not declared by `AggregateRoot`: TypeScript has no abstract static methods.
192
+
193
+ ### `record(event)` <Badge type="info" text="protected" /> <Badge type="tip" text="inside your methods" />
194
+
195
+ ```ts
196
+ protected record(event: Event): void
197
+ ```
198
+
199
+ Records a domain event, to be pulled after the aggregate is saved. Only the aggregate records its
200
+ events.
201
+
202
+ ### `pullDomainEvents()` <Badge type="tip" text="called by the command handler" />
203
+
204
+ ```ts
205
+ pullDomainEvents(): Event[]
206
+ ```
207
+
208
+ Returns the recorded events, in order, and clears them. Call it after saving, then add the events
209
+ to the outbox.
210
+
211
+ ### `domainEvents` <Badge type="info" text="getter" /> <Badge type="tip" text="read by tests" />
212
+
213
+ ```ts
214
+ get domainEvents(): readonly Event[]
215
+ ```
216
+
217
+ Returns a copy of the recorded events, without clearing them.
218
+
219
+ ### `equals(other)` <Badge type="tip" text="called by anyone" />
220
+
221
+ ```ts
222
+ equals(other: AnyEntity): boolean
223
+ ```
224
+
225
+ `true` when `other` is the same object, or an instance of the same class with an equal
226
+ identifier.
227
+
228
+ ### `id` <Badge type="info" text="readonly" /> <Badge type="tip" text="read by anyone" />
229
+
230
+ ```ts
231
+ readonly id: Id
232
+ ```
233
+
234
+ The identifier given to the constructor.
235
+
236
+ ::: warning Caveats
237
+ - TypeScript has no abstract static methods: the compiler does not check that `fromSnapshot`
238
+ exists.
239
+ - Declare the snapshot with `type`, not `interface`: an interface does not satisfy `AnySnapshot`.
240
+ - No version is kept: to prevent lost updates, put a `version` in your snapshot and check it in the
241
+ repository adapter.
242
+ :::
243
+
244
+ ## Usage
245
+
246
+ Build the `Order` aggregate of the running example, one rule at a time. Each step shows the whole
247
+ file: added lines are highlighted, replaced lines are struck out.
248
+
249
+ <div class="al-cards">
250
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-name-it">Name it</a></span>Give the aggregate its identifier.</div>
251
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-root">Declare the root</a></span>One class, one way in.</div>
252
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-make-it-storable">Make it storable</a></span>Save and restore its state.</div>
253
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-change-it-through-a-method">Change it through a method</a></span>No setter, a business method.</div>
254
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-guard-a-rule">Guard a rule</a></span>Refuse what breaks the rules.</div>
255
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-record-what-happened">Record what happened</a></span>A domain event for the rest of the system.</div>
256
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">7</span><a href="#_7-expose-reads-as-getters">Expose reads as getters</a></span>Let callers read without changing.</div>
257
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">8</span><a href="#_8-call-it-from-a-handler">Call it from a handler</a></span>Load, change, save, hand over.</div>
258
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">9</span><a href="#_9-check-it">Check it</a></span>Let the rules keep it that way.</div>
259
+ </div>
260
+
261
+ ### 1. Name it
262
+
263
+ An aggregate is known by its identifier: declare `OrderId` as an
264
+ [identifier](./value-objects.md#identifier), in `domain/value-objects/order-id.identifier.ts`.
265
+
266
+ ### 2. Declare the root
267
+
268
+ So that nothing creates an order in a wrong state, the constructor is private and a static factory
269
+ is the only way in. The order keeps its customer as a `CustomerId`, never as a `Customer`.
270
+
271
+ ```ts [src/ordering/domain/aggregates/order.aggregate.ts]
272
+ import { AggregateRoot } from "@alveolus/core";
273
+
274
+ import { CustomerId } from "../value-objects/customer-id.identifier";
275
+ import { OrderId } from "../value-objects/order-id.identifier";
276
+
277
+ type OrderStatus = "draft" | "placed";
278
+
279
+ export class Order extends AggregateRoot<OrderId> {
280
+ private constructor(
281
+ id: OrderId,
282
+ private readonly customerId: CustomerId,
283
+ private status: OrderStatus,
284
+ ) {
285
+ super(id);
286
+ }
287
+
288
+ static create(id: OrderId, customerId: CustomerId): Order {
289
+ return new Order(id, customerId, "draft");
290
+ }
291
+ }
292
+ ```
293
+
294
+ `id` is not a field of `Order`: it is passed to `super`, which stores it as the public, read-only
295
+ identifier. TypeScript now asks for `toSnapshot()`: the next step adds it.
296
+
297
+ ### 3. Make it storable
298
+
299
+ The state is private, so the repository cannot read the fields: it saves a snapshot, plain data
300
+ declared with `type`. `fromSnapshot` rebuilds the order without checking rules or recording
301
+ events. The order records no event yet, hence `never`.
302
+
303
+ ```ts [src/ordering/domain/aggregates/order.aggregate.ts]
304
+ import { AggregateRoot } from "@alveolus/core";
305
+
306
+ import { CustomerId } from "../value-objects/customer-id.identifier";
307
+ import { OrderId } from "../value-objects/order-id.identifier";
308
+
309
+ type OrderStatus = "draft" | "placed";
310
+
311
+ export type OrderSnapshot = { // [!code ++]
312
+ readonly id: string; // [!code ++]
313
+ readonly customerId: string; // [!code ++]
314
+ readonly status: OrderStatus; // [!code ++]
315
+ }; // [!code ++]
316
+
317
+ export class Order extends AggregateRoot<OrderId> { // [!code --]
318
+ export class Order extends AggregateRoot< // [!code ++]
319
+ OrderId, // [!code ++]
320
+ never, // [!code ++]
321
+ OrderSnapshot // [!code ++]
322
+ > { // [!code ++]
323
+ private constructor(
324
+ id: OrderId,
325
+ private readonly customerId: CustomerId,
326
+ private status: OrderStatus,
327
+ ) {
328
+ super(id);
329
+ }
330
+
331
+ static create(id: OrderId, customerId: CustomerId): Order {
332
+ return new Order(id, customerId, "draft");
333
+ }
334
+
335
+ static fromSnapshot(snapshot: OrderSnapshot): Order { // [!code ++]
336
+ return new Order( // [!code ++]
337
+ new OrderId(snapshot.id), // [!code ++]
338
+ new CustomerId(snapshot.customerId), // [!code ++]
339
+ snapshot.status, // [!code ++]
340
+ ); // [!code ++]
341
+ } // [!code ++]
342
+
343
+ toSnapshot(): OrderSnapshot { // [!code ++]
344
+ return { // [!code ++]
345
+ id: this.id.value, // [!code ++]
346
+ customerId: this.customerId.value, // [!code ++]
347
+ status: this.status, // [!code ++]
348
+ }; // [!code ++]
349
+ } // [!code ++]
350
+ }
351
+ ```
352
+
353
+ ### 4. Change it through a method
354
+
355
+ So that no caller can skip a rule, the order has no setter: it changes through a method named
356
+ after what the business does. A failure is returned in a `Result`, never thrown, so the caller
357
+ sees it in the signature.
358
+
359
+ ```ts [src/ordering/domain/aggregates/order.aggregate.ts]
360
+ import { AggregateRoot } from "@alveolus/core"; // [!code --]
361
+ import { AggregateRoot, err, ok, type Result } from "@alveolus/core"; // [!code ++]
362
+
363
+ import { // [!code ++]
364
+ OrderLine, // [!code ++]
365
+ type OrderLineSnapshot, // [!code ++]
366
+ } from "../entities/order-line.entity"; // [!code ++]
367
+ import { // [!code ++]
368
+ OrderAlreadyPlaced, // [!code ++]
369
+ } from "../errors/order-already-placed.error"; // [!code ++]
370
+ import { CustomerId } from "../value-objects/customer-id.identifier";
371
+ import { OrderId } from "../value-objects/order-id.identifier";
372
+ import { OrderLineId } from "../value-objects/order-line-id.identifier"; // [!code ++]
373
+ import { ProductId } from "../value-objects/product-id.identifier"; // [!code ++]
374
+
375
+ type OrderStatus = "draft" | "placed";
376
+
377
+ export type OrderSnapshot = {
378
+ readonly id: string;
379
+ readonly customerId: string;
380
+ readonly status: OrderStatus;
381
+ readonly lines: readonly OrderLineSnapshot[]; // [!code ++]
382
+ };
383
+
384
+ export class Order extends AggregateRoot<
385
+ OrderId,
386
+ never,
387
+ OrderSnapshot
388
+ > {
389
+ private constructor(
390
+ id: OrderId,
391
+ private readonly customerId: CustomerId,
392
+ private status: OrderStatus,
393
+ private readonly lines: OrderLine[], // [!code ++]
394
+ ) {
395
+ super(id);
396
+ }
397
+
398
+ static create(id: OrderId, customerId: CustomerId): Order {
399
+ return new Order(id, customerId, "draft"); // [!code --]
400
+ return new Order(id, customerId, "draft", []); // [!code ++]
401
+ }
402
+
403
+ static fromSnapshot(snapshot: OrderSnapshot): Order {
404
+ return new Order(
405
+ new OrderId(snapshot.id),
406
+ new CustomerId(snapshot.customerId),
407
+ snapshot.status,
408
+ snapshot.lines.map((line) => // [!code ++]
409
+ OrderLine.fromSnapshot(line), // [!code ++]
410
+ ), // [!code ++]
411
+ );
412
+ }
413
+
414
+ addLine( // [!code ++]
415
+ lineId: OrderLineId, // [!code ++]
416
+ productId: ProductId, // [!code ++]
417
+ ): Result<void, OrderAlreadyPlaced> { // [!code ++]
418
+ if (this.status === "placed") { // [!code ++]
419
+ return err(new OrderAlreadyPlaced()); // [!code ++]
420
+ } // [!code ++]
421
+ this.lines.push(OrderLine.create(lineId, productId)); // [!code ++]
422
+ return ok(); // [!code ++]
423
+ } // [!code ++]
424
+
425
+ toSnapshot(): OrderSnapshot {
426
+ return {
427
+ id: this.id.value,
428
+ customerId: this.customerId.value,
429
+ status: this.status,
430
+ lines: this.lines.map((line) => line.toSnapshot()), // [!code ++]
431
+ };
432
+ }
433
+ }
434
+ ```
435
+
436
+ `OrderLine` is an [entity](./entities.md) inside the aggregate: only the order creates and
437
+ changes it.
438
+
439
+ ### 5. Guard a rule
440
+
441
+ An order cannot be placed empty, nor twice. The rule lives in `place`, so it holds whichever
442
+ handler, test or script calls it. Nothing changes when a rule is broken.
443
+
444
+ ```ts [src/ordering/domain/aggregates/order.aggregate.ts]
445
+ import { AggregateRoot, err, ok, type Result } from "@alveolus/core";
446
+
447
+ import {
448
+ OrderLine,
449
+ type OrderLineSnapshot,
450
+ } from "../entities/order-line.entity";
451
+ import { EmptyOrder } from "../errors/empty-order.error"; // [!code ++]
452
+ import {
453
+ OrderAlreadyPlaced,
454
+ } from "../errors/order-already-placed.error";
455
+ import { CustomerId } from "../value-objects/customer-id.identifier";
456
+ import { OrderId } from "../value-objects/order-id.identifier";
457
+ import { OrderLineId } from "../value-objects/order-line-id.identifier";
458
+ import { ProductId } from "../value-objects/product-id.identifier";
459
+
460
+ type OrderStatus = "draft" | "placed";
461
+
462
+ export type OrderSnapshot = {
463
+ readonly id: string;
464
+ readonly customerId: string;
465
+ readonly status: OrderStatus;
466
+ readonly lines: readonly OrderLineSnapshot[];
467
+ };
468
+
469
+ export class Order extends AggregateRoot<
470
+ OrderId,
471
+ never,
472
+ OrderSnapshot
473
+ > {
474
+ private constructor(
475
+ id: OrderId,
476
+ private readonly customerId: CustomerId,
477
+ private status: OrderStatus,
478
+ private readonly lines: OrderLine[],
479
+ ) {
480
+ super(id);
481
+ }
482
+
483
+ static create(id: OrderId, customerId: CustomerId): Order {
484
+ return new Order(id, customerId, "draft", []);
485
+ }
486
+
487
+ static fromSnapshot(snapshot: OrderSnapshot): Order {
488
+ return new Order(
489
+ new OrderId(snapshot.id),
490
+ new CustomerId(snapshot.customerId),
491
+ snapshot.status,
492
+ snapshot.lines.map((line) =>
493
+ OrderLine.fromSnapshot(line),
494
+ ),
495
+ );
496
+ }
497
+
498
+ addLine(
499
+ lineId: OrderLineId,
500
+ productId: ProductId,
501
+ ): Result<void, OrderAlreadyPlaced> {
502
+ if (this.status === "placed") {
503
+ return err(new OrderAlreadyPlaced());
504
+ }
505
+ this.lines.push(OrderLine.create(lineId, productId));
506
+ return ok();
507
+ }
508
+
509
+ place(): Result<void, OrderAlreadyPlaced | EmptyOrder> { // [!code ++]
510
+ if (this.status === "placed") { // [!code ++]
511
+ return err(new OrderAlreadyPlaced()); // [!code ++]
512
+ } // [!code ++]
513
+ if (this.lines.length === 0) { // [!code ++]
514
+ return err(new EmptyOrder()); // [!code ++]
515
+ } // [!code ++]
516
+ this.status = "placed"; // [!code ++]
517
+ return ok(); // [!code ++]
518
+ } // [!code ++]
519
+
520
+ toSnapshot(): OrderSnapshot {
521
+ return {
522
+ id: this.id.value,
523
+ customerId: this.customerId.value,
524
+ status: this.status,
525
+ lines: this.lines.map((line) => line.toSnapshot()),
526
+ };
527
+ }
528
+ }
529
+ ```
530
+
531
+ ### 6. Record what happened
532
+
533
+ The rest of the system must learn that an order was placed. The order records an `OrderPlaced`
534
+ [domain event](./domain-events.md) but never publishes it. So that the same call always gives the
535
+ same result, the event id and the date are passed in, never read from a clock or generated.
536
+
537
+ ```ts [src/ordering/domain/aggregates/order.aggregate.ts]
538
+ import { AggregateRoot, err, ok, type Result } from "@alveolus/core";
539
+
540
+ import {
541
+ OrderLine,
542
+ type OrderLineSnapshot,
543
+ } from "../entities/order-line.entity";
544
+ import { EmptyOrder } from "../errors/empty-order.error";
545
+ import {
546
+ OrderAlreadyPlaced,
547
+ } from "../errors/order-already-placed.error";
548
+ import { OrderPlaced } from "../events/order-placed.event"; // [!code ++]
549
+ import { CustomerId } from "../value-objects/customer-id.identifier";
550
+ import { OrderId } from "../value-objects/order-id.identifier";
551
+ import { OrderLineId } from "../value-objects/order-line-id.identifier";
552
+ import { ProductId } from "../value-objects/product-id.identifier";
553
+
554
+ type OrderStatus = "draft" | "placed";
555
+
556
+ export type OrderSnapshot = {
557
+ readonly id: string;
558
+ readonly customerId: string;
559
+ readonly status: OrderStatus;
560
+ readonly lines: readonly OrderLineSnapshot[];
561
+ };
562
+
563
+ export class Order extends AggregateRoot<
564
+ OrderId,
565
+ never, // [!code --]
566
+ OrderPlaced, // [!code ++]
567
+ OrderSnapshot
568
+ > {
569
+ private constructor(
570
+ id: OrderId,
571
+ private readonly customerId: CustomerId,
572
+ private status: OrderStatus,
573
+ private readonly lines: OrderLine[],
574
+ ) {
575
+ super(id);
576
+ }
577
+
578
+ static create(id: OrderId, customerId: CustomerId): Order {
579
+ return new Order(id, customerId, "draft", []);
580
+ }
581
+
582
+ static fromSnapshot(snapshot: OrderSnapshot): Order {
583
+ return new Order(
584
+ new OrderId(snapshot.id),
585
+ new CustomerId(snapshot.customerId),
586
+ snapshot.status,
587
+ snapshot.lines.map((line) =>
588
+ OrderLine.fromSnapshot(line),
589
+ ),
590
+ );
591
+ }
592
+
593
+ addLine(
594
+ lineId: OrderLineId,
595
+ productId: ProductId,
596
+ ): Result<void, OrderAlreadyPlaced> {
597
+ if (this.status === "placed") {
598
+ return err(new OrderAlreadyPlaced());
599
+ }
600
+ this.lines.push(OrderLine.create(lineId, productId));
601
+ return ok();
602
+ }
603
+
604
+ place(): Result<void, OrderAlreadyPlaced | EmptyOrder> { // [!code --]
605
+ place( // [!code ++]
606
+ eventId: string, // [!code ++]
607
+ now: Date, // [!code ++]
608
+ ): Result<void, OrderAlreadyPlaced | EmptyOrder> { // [!code ++]
609
+ if (this.status === "placed") {
610
+ return err(new OrderAlreadyPlaced());
611
+ }
612
+ if (this.lines.length === 0) {
613
+ return err(new EmptyOrder());
614
+ }
615
+ this.status = "placed";
616
+ this.record( // [!code ++]
617
+ new OrderPlaced({ // [!code ++]
618
+ id: eventId, // [!code ++]
619
+ aggregateId: this.id, // [!code ++]
620
+ occurredAt: now, // [!code ++]
621
+ payload: { customerId: this.customerId.value }, // [!code ++]
622
+ }), // [!code ++]
623
+ ); // [!code ++]
624
+ return ok();
625
+ }
626
+
627
+ toSnapshot(): OrderSnapshot {
628
+ return {
629
+ id: this.id.value,
630
+ customerId: this.customerId.value,
631
+ status: this.status,
632
+ lines: this.lines.map((line) => line.toSnapshot()),
633
+ };
634
+ }
635
+ }
636
+ ```
637
+
638
+ ### 7. Expose reads as getters
639
+
640
+ Callers need to read the state without changing it. Reads are getters: a public method must
641
+ return a `Result`, a getter does not. The methods use them too.
642
+
643
+ ```ts [src/ordering/domain/aggregates/order.aggregate.ts]
644
+ import { AggregateRoot, err, ok, type Result } from "@alveolus/core";
645
+
646
+ import {
647
+ OrderLine,
648
+ type OrderLineSnapshot,
649
+ } from "../entities/order-line.entity";
650
+ import { EmptyOrder } from "../errors/empty-order.error";
651
+ import {
652
+ OrderAlreadyPlaced,
653
+ } from "../errors/order-already-placed.error";
654
+ import { OrderPlaced } from "../events/order-placed.event";
655
+ import { CustomerId } from "../value-objects/customer-id.identifier";
656
+ import { OrderId } from "../value-objects/order-id.identifier";
657
+ import { OrderLineId } from "../value-objects/order-line-id.identifier";
658
+ import { ProductId } from "../value-objects/product-id.identifier";
659
+
660
+ type OrderStatus = "draft" | "placed";
661
+
662
+ export type OrderSnapshot = {
663
+ readonly id: string;
664
+ readonly customerId: string;
665
+ readonly status: OrderStatus;
666
+ readonly lines: readonly OrderLineSnapshot[];
667
+ };
668
+
669
+ export class Order extends AggregateRoot<
670
+ OrderId,
671
+ OrderPlaced,
672
+ OrderSnapshot
673
+ > {
674
+ private constructor(
675
+ id: OrderId,
676
+ private readonly customerId: CustomerId,
677
+ private status: OrderStatus,
678
+ private readonly lines: OrderLine[],
679
+ ) {
680
+ super(id);
681
+ }
682
+
683
+ static create(id: OrderId, customerId: CustomerId): Order {
684
+ return new Order(id, customerId, "draft", []);
685
+ }
686
+
687
+ get isPlaced(): boolean { // [!code ++]
688
+ return this.status === "placed"; // [!code ++]
689
+ } // [!code ++]
690
+
691
+ get lineCount(): number { // [!code ++]
692
+ return this.lines.length; // [!code ++]
693
+ } // [!code ++]
694
+
695
+ static fromSnapshot(snapshot: OrderSnapshot): Order {
696
+ return new Order(
697
+ new OrderId(snapshot.id),
698
+ new CustomerId(snapshot.customerId),
699
+ snapshot.status,
700
+ snapshot.lines.map((line) =>
701
+ OrderLine.fromSnapshot(line),
702
+ ),
703
+ );
704
+ }
705
+
706
+ addLine(
707
+ lineId: OrderLineId,
708
+ productId: ProductId,
709
+ ): Result<void, OrderAlreadyPlaced> {
710
+ if (this.status === "placed") { // [!code --]
711
+ if (this.isPlaced) { // [!code ++]
712
+ return err(new OrderAlreadyPlaced());
713
+ }
714
+ this.lines.push(OrderLine.create(lineId, productId));
715
+ return ok();
716
+ }
717
+
718
+ place(
719
+ eventId: string,
720
+ now: Date,
721
+ ): Result<void, OrderAlreadyPlaced | EmptyOrder> {
722
+ if (this.status === "placed") { // [!code --]
723
+ if (this.isPlaced) { // [!code ++]
724
+ return err(new OrderAlreadyPlaced());
725
+ }
726
+ if (this.lines.length === 0) {
727
+ return err(new EmptyOrder());
728
+ }
729
+ this.status = "placed";
730
+ this.record(
731
+ new OrderPlaced({
732
+ id: eventId,
733
+ aggregateId: this.id,
734
+ occurredAt: now,
735
+ payload: { customerId: this.customerId.value },
736
+ }),
737
+ );
738
+ return ok();
739
+ }
740
+
741
+ toSnapshot(): OrderSnapshot {
742
+ return {
743
+ id: this.id.value,
744
+ customerId: this.customerId.value,
745
+ status: this.status,
746
+ lines: this.lines.map((line) => line.toSnapshot()),
747
+ };
748
+ }
749
+ }
750
+ ```
751
+
752
+ ### 8. Call it from a handler
753
+
754
+ A [command handler](../application/command-handlers.md) loads the order, calls one method, saves
755
+ it and hands its events over to the [outbox](../application/outbox.md), in one
756
+ [unit of work](../application/unit-of-work.md). The time and the ids come from the `Clock` and
757
+ `IdGenerator` [ports](./ports.md).
758
+
759
+ ```ts [src/ordering/application/commands/place-order.command.ts]
760
+ return this.unitOfWork.run(async () => {
761
+ const order = await this.orders.findById(
762
+ new OrderId(orderId),
763
+ );
764
+ if (order === undefined) {
765
+ return err(new OrderNotFound({ orderId }));
766
+ }
767
+ const placed = order.place(
768
+ this.ids.next(),
769
+ this.clock.now(),
770
+ );
771
+ if (!placed.ok) {
772
+ return placed;
773
+ }
774
+ await this.orders.save(order);
775
+ const events = order
776
+ .pullDomainEvents()
777
+ .map((event) =>
778
+ this.translator.translate(event, { correlationId }),
779
+ );
780
+ await this.outbox.add(events);
781
+ return ok();
782
+ });
783
+ ```
784
+
785
+ ### 9. Check it
786
+
787
+ Run the checks. Three rules keep the aggregate the way it is now:
788
+
789
+ ```sh
790
+ npx alveolus arch check
791
+ ```
792
+
793
+ <div class="al-cards">
794
+ <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/aggregates/*.aggregate.ts</code>.</div>
795
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-thrown-failure"><code>no-thrown-failure</code></a></span>Its public methods return a <code>Result</code>, and nothing is thrown.</div>
796
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-aggregate-reference"><code>no-aggregate-reference</code></a></span>It keeps a <code>CustomerId</code>, never a <code>Customer</code>.</div>
797
+ </div>
798
+
799
+ A setter added later is reported:
800
+
801
+ ```
802
+ src/ordering/domain/aggregates/order.aggregate.ts
803
+ 42 error tactical/no-thrown-failure: Order.setStatus must return a
804
+ Result: expose reads as getters and return business failures
805
+ as values.
806
+ ```
807
+
808
+ ## Troubleshooting
809
+
810
+ **`Type 'OrderSnapshot' does not satisfy the constraint 'AnySnapshot'`**: the snapshot is an
811
+ `interface`, or holds a value object or an entity. Declare it with `type` and write value objects
812
+ as plain fields.
813
+
814
+ ## See also
815
+
816
+ - [Entities](./entities.md) and [Value objects](./value-objects.md), inside an aggregate
817
+ - [Domain events](./domain-events.md) and [Domain errors](./domain-errors.md), what it records and returns
818
+ - [Repositories](./repositories.md), to load and save it, and
819
+ [Command handlers](../application/command-handlers.md), to call it
820
+ - Rules: [`tactical/no-thrown-failure`](../../rules/tactical/no-thrown-failure.md), [`tactical/no-aggregate-reference`](../../rules/tactical/no-aggregate-reference.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
821
+ - Vaughn Vernon, *Domain-Driven Design Distilled*, chapter 5, "Tactical Design with Aggregates"
822
+ - Vaughn Vernon, *Implementing Domain-Driven Design*, chapter 10, "Aggregates"