@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,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<Id, Event, Snapshot></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"
|