@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.
- package/README.md +11 -0
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-P5PwH9OE.mjs → docs-DcFgskuN.mjs} +214 -43
- package/dist/docs-DcFgskuN.mjs.map +1 -0
- package/dist/index.d.mts +53 -12
- 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 +108 -0
- package/docs/guide/getting-started.md +286 -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 +185 -0
- package/docs/rules/layers/no-driving-shortcut.md +119 -0
- package/docs/rules/layers/no-impure-domain.md +191 -0
- package/docs/rules/layers/no-outward-import.md +186 -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 +114 -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 +201 -0
- package/docs/rules/tactical/no-loose-code.md +171 -0
- package/docs/rules/tactical/no-misplaced-class.md +146 -0
- package/docs/rules/tactical/no-public-field.md +113 -0
- package/docs/rules/tactical/no-stateful-service.md +102 -0
- package/docs/rules/tactical/no-thrown-failure.md +162 -0
- package/docs/rules/tooling/no-loose-disable.md +98 -0
- package/package.json +4 -3
- package/dist/cli-P5PwH9OE.mjs.map +0 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Domain services in Domain-Driven Design with TypeScript: stateless classes that hold a business rule no single aggregate or value object owns."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Domain Services
|
|
6
|
+
|
|
7
|
+
A domain service is a stateless class of the domain that holds a business rule no single object
|
|
8
|
+
owns.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Domain</dd>
|
|
12
|
+
<dt>File</dt><dd><code>domain/services/order-limit.service.ts</code></dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>DomainService</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-loose-code"><code>tactical/no-loose-code</code></a>, <a href="/rules/layers/no-impure-domain"><code>layers/no-impure-domain</code></a>, <a href="/rules/tactical/no-stateful-service"><code>tactical/no-stateful-service</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
A new customer may not place an order of more than 5 lines. The rule needs the customer and the
|
|
21
|
+
order. Put it in `Order`, and the order must hold the `Customer`: two aggregates merge. Put it in
|
|
22
|
+
the command handler, and the rule leaves the domain, copied in every handler that places orders.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
A domain service holds the rule: `OrderLimit` takes the order and the customer, and answers with a
|
|
26
|
+
`Result`. The rule stays in the domain, in one place, and neither aggregate holds the other.
|
|
27
|
+
:::
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
A domain service is a plain function of the domain, written as a class: it keeps nothing between
|
|
32
|
+
calls. It never loads, saves or publishes: the command handler does that, and hands the service
|
|
33
|
+
what it needs.
|
|
34
|
+
|
|
35
|
+
<div class="al-cards">
|
|
36
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Receive</span>The aggregates and values the rule needs, as parameters.</div>
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Decide</span>Apply the rule, in the words of the domain.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Answer</span>Return a <a href="/core/utilities/result"><code>Result</code></a>: a value, or a <a href="/core/domain/domain-errors">domain error</a>.</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
check(order: Order, customer: Customer): Result<void, OrderTooLarge> {
|
|
43
|
+
if (!customer.isNew) {
|
|
44
|
+
return ok();
|
|
45
|
+
}
|
|
46
|
+
if (order.lineCount <= this.maxLinesForNewCustomers) {
|
|
47
|
+
return ok();
|
|
48
|
+
}
|
|
49
|
+
return err(new OrderTooLarge({ limit: this.maxLinesForNewCustomers }));
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Where it fits
|
|
54
|
+
|
|
55
|
+
The [command handler](../application/command-handlers.md) loads both aggregates, asks the service,
|
|
56
|
+
and only then calls the aggregate it changes.
|
|
57
|
+
|
|
58
|
+
<div class="al-diagram">
|
|
59
|
+
<svg viewBox="0 0 680 380" role="img" aria-label="The PlaceOrderHandler loads the Order and the Customer, asks the OrderLimit domain service, then places the order and saves it.">
|
|
60
|
+
<defs>
|
|
61
|
+
<marker id="service-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
62
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
63
|
+
</marker>
|
|
64
|
+
</defs>
|
|
65
|
+
<rect class="box" x="8" y="162" width="130" height="56" rx="8" />
|
|
66
|
+
<text class="label" x="73" y="186" text-anchor="middle">Controller</text>
|
|
67
|
+
<text class="note" x="73" y="206" text-anchor="middle">driving adapter</text>
|
|
68
|
+
<path class="link" d="M 138 190 L 178 190" marker-end="url(#service-flow-arrow)" />
|
|
69
|
+
<rect class="box" x="180" y="162" width="180" height="56" rx="8" />
|
|
70
|
+
<text class="label" x="270" y="186" text-anchor="middle">PlaceOrderHandler</text>
|
|
71
|
+
<text class="note" x="270" y="206" text-anchor="middle">command handler</text>
|
|
72
|
+
<rect class="box" x="440" y="24" width="232" height="48" rx="8" />
|
|
73
|
+
<text class="label" x="556" y="44" text-anchor="middle">1 · orders.findById(…)</text>
|
|
74
|
+
<text class="note" x="556" y="62" text-anchor="middle">loads the Order</text>
|
|
75
|
+
<rect class="box" x="440" y="92" width="232" height="48" rx="8" />
|
|
76
|
+
<text class="label" x="556" y="112" text-anchor="middle">2 · customers.findById(…)</text>
|
|
77
|
+
<text class="note" x="556" y="130" text-anchor="middle">loads the Customer</text>
|
|
78
|
+
<rect class="boundary" x="440" y="160" width="232" height="48" rx="8" />
|
|
79
|
+
<text class="label" x="556" y="180" text-anchor="middle">3 · orderLimit.check(…)</text>
|
|
80
|
+
<text class="note" x="556" y="198" text-anchor="middle">this page: rule on both</text>
|
|
81
|
+
<rect class="box" x="440" y="228" width="232" height="48" rx="8" />
|
|
82
|
+
<text class="label" x="556" y="248" text-anchor="middle">4 · order.place(…)</text>
|
|
83
|
+
<text class="note" x="556" y="266" text-anchor="middle">changes the Order</text>
|
|
84
|
+
<rect class="box" x="440" y="296" width="232" height="48" rx="8" />
|
|
85
|
+
<text class="label" x="556" y="316" text-anchor="middle">5 · orders.save(order)</text>
|
|
86
|
+
<text class="note" x="556" y="334" text-anchor="middle">stores it</text>
|
|
87
|
+
<path class="link" d="M 360 190 L 438 48" marker-end="url(#service-flow-arrow)" />
|
|
88
|
+
<path class="link" d="M 360 190 L 438 116" marker-end="url(#service-flow-arrow)" />
|
|
89
|
+
<path class="link" d="M 360 190 L 438 184" marker-end="url(#service-flow-arrow)" />
|
|
90
|
+
<path class="link" d="M 360 190 L 438 252" marker-end="url(#service-flow-arrow)" />
|
|
91
|
+
<path class="link" d="M 360 190 L 438 320" marker-end="url(#service-flow-arrow)" />
|
|
92
|
+
</svg>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
::: tip
|
|
96
|
+
The service reads two aggregates but changes none of them. Only `Order` changes, in one
|
|
97
|
+
transaction.
|
|
98
|
+
:::
|
|
99
|
+
|
|
100
|
+
## API
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
import { DomainService } from "@alveolus/core";
|
|
104
|
+
// or: import { DomainService } from "@alveolus/core/domain-services";
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Declaration
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
abstract class DomainService {}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
`DomainService` has no members: extending it marks the class as a domain service, for you and for
|
|
114
|
+
`alveolus arch check`.
|
|
115
|
+
|
|
116
|
+
### `constructor(…)` <Badge type="info" text="optional" /> <Badge type="tip" text="you write it" />
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
constructor(private readonly limit: number)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Takes configuration, such as a limit or a rate, in `readonly` fields, and calls `super()`.
|
|
123
|
+
|
|
124
|
+
### Your methods <Badge type="tip" text="called by the command handler" />
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
check(order: Order, customer: Customer): Result<void, OrderTooLarge>
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Take domain objects and return a `Result` when the rule can refuse.
|
|
131
|
+
|
|
132
|
+
::: warning Caveats
|
|
133
|
+
- A domain service is not an application service: it never loads, saves or publishes. That is the
|
|
134
|
+
job of a [command handler](../application/command-handlers.md).
|
|
135
|
+
- It may hold configuration in `readonly` fields, never an aggregate or an entity.
|
|
136
|
+
:::
|
|
137
|
+
|
|
138
|
+
## Usage
|
|
139
|
+
|
|
140
|
+
Build `OrderLimit`, a rule that needs two aggregates, then call it from the handler that places an
|
|
141
|
+
order. Each step shows the whole file it changes: added lines are highlighted, replaced lines are struck out.
|
|
142
|
+
|
|
143
|
+
<div class="al-cards">
|
|
144
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-find-the-rule">Find the rule</a></span>One that belongs to no aggregate.</div>
|
|
145
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-service">Declare the service</a></span>A home named after the rule.</div>
|
|
146
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-check-the-rule">Check the rule</a></span>Aggregates in, a Result out.</div>
|
|
147
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-call-it-from-a-handler">Call it from a handler</a></span>Load, check, then change.</div>
|
|
148
|
+
<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>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
### 1. Find the rule
|
|
152
|
+
|
|
153
|
+
A new customer may not place an order with more than a few lines: the rule needs the
|
|
154
|
+
[`Order`](./aggregates.md) and the `Customer`, and belongs to neither.
|
|
155
|
+
|
|
156
|
+
### 2. Declare the service
|
|
157
|
+
|
|
158
|
+
So that the rule has a home in the domain, it gets a class of its own, named after the rule. Its
|
|
159
|
+
settings come in through the constructor.
|
|
160
|
+
|
|
161
|
+
```ts [src/ordering/domain/services/order-limit.service.ts]
|
|
162
|
+
import { DomainService } from "@alveolus/core";
|
|
163
|
+
|
|
164
|
+
export class OrderLimit extends DomainService {
|
|
165
|
+
constructor(private readonly maxLinesForNewCustomers: number) {
|
|
166
|
+
super();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 3. Check the rule
|
|
172
|
+
|
|
173
|
+
The service takes the aggregates as parameters and changes neither. Like an aggregate, it returns
|
|
174
|
+
the failure in a `Result`.
|
|
175
|
+
|
|
176
|
+
```ts [src/ordering/domain/services/order-limit.service.ts]
|
|
177
|
+
import { DomainService } from "@alveolus/core"; // [!code --]
|
|
178
|
+
import { DomainService, err, ok, type Result } from "@alveolus/core"; // [!code ++]
|
|
179
|
+
|
|
180
|
+
import type { Customer } from "../aggregates/customer.aggregate"; // [!code ++]
|
|
181
|
+
import type { Order } from "../aggregates/order.aggregate"; // [!code ++]
|
|
182
|
+
import { OrderTooLarge } from "../errors/order-too-large.error"; // [!code ++]
|
|
183
|
+
|
|
184
|
+
export class OrderLimit extends DomainService {
|
|
185
|
+
constructor(private readonly maxLinesForNewCustomers: number) {
|
|
186
|
+
super();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
check( // [!code ++]
|
|
190
|
+
order: Order, // [!code ++]
|
|
191
|
+
customer: Customer, // [!code ++]
|
|
192
|
+
): Result<void, OrderTooLarge> { // [!code ++]
|
|
193
|
+
if (!customer.isNew) { // [!code ++]
|
|
194
|
+
return ok(); // [!code ++]
|
|
195
|
+
} // [!code ++]
|
|
196
|
+
if (order.lineCount <= this.maxLinesForNewCustomers) { // [!code ++]
|
|
197
|
+
return ok(); // [!code ++]
|
|
198
|
+
} // [!code ++]
|
|
199
|
+
return err( // [!code ++]
|
|
200
|
+
new OrderTooLarge({ limit: this.maxLinesForNewCustomers }), // [!code ++]
|
|
201
|
+
); // [!code ++]
|
|
202
|
+
} // [!code ++]
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### 4. Call it from a handler
|
|
207
|
+
|
|
208
|
+
The [command handler](../application/command-handlers.md) loads the order and the customer, asks
|
|
209
|
+
the service, and only then changes the order. The composition root sets the limit with
|
|
210
|
+
`new OrderLimit(5)`.
|
|
211
|
+
|
|
212
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
213
|
+
const allowed = this.orderLimit.check(order, customer);
|
|
214
|
+
if (!allowed.ok) {
|
|
215
|
+
return allowed;
|
|
216
|
+
}
|
|
217
|
+
const placed = order.place(this.ids.next(), this.clock.now());
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### 5. Check it
|
|
221
|
+
|
|
222
|
+
Run the checks. Three rules keep the service the way it is now:
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
npx alveolus arch check
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
<div class="al-cards">
|
|
229
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-loose-code"><code>no-loose-code</code></a></span>The rule is a method of a <code>DomainService</code>, not a free function.</div>
|
|
230
|
+
<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/services/*.service.ts</code>.</div>
|
|
231
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-impure-domain"><code>no-impure-domain</code></a></span>It imports the domain only: no adapter, no framework.</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
The same rule written as a function is reported:
|
|
235
|
+
|
|
236
|
+
```
|
|
237
|
+
src/ordering/domain/services/order-limit.ts
|
|
238
|
+
5 error tactical/no-loose-code: The function checkOrderLimit floats
|
|
239
|
+
outside any class: make it a method of a value object or of a
|
|
240
|
+
DomainService.
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
## See also
|
|
244
|
+
|
|
245
|
+
- [Aggregates](./aggregates.md), where most rules belong
|
|
246
|
+
- [Value objects](./value-objects.md), the other home for calculations
|
|
247
|
+
- [Command handlers](../application/command-handlers.md), which call domain services
|
|
248
|
+
- Rules: [`tactical/no-loose-code`](../../rules/tactical/no-loose-code.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
|
|
249
|
+
- Vaughn Vernon, *Implementing Domain-Driven Design*, chapter 7, "Services"
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Entities in Domain-Driven Design with TypeScript: objects inside an aggregate that keep their identity while their attributes change."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Entities
|
|
6
|
+
|
|
7
|
+
An entity is an object inside an aggregate that keeps its identity while its attributes change.
|
|
8
|
+
|
|
9
|
+
<dl class="al-glance">
|
|
10
|
+
<dt>Layer</dt><dd>Domain</dd>
|
|
11
|
+
<dt>File</dt><dd><code>domain/entities/order-line.entity.ts</code></dd>
|
|
12
|
+
<dt>Extends</dt><dd><a href="#api"><code>Entity<Id, Snapshot></code></a></dd>
|
|
13
|
+
<dt>Called by</dt><dd>The root of its <a href="/core/domain/aggregates">aggregate</a>, and nothing else</dd>
|
|
14
|
+
<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>
|
|
15
|
+
</dl>
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
An order has two lines for the same product, one of 1 and one of 3. The customer changes the second
|
|
20
|
+
one to 0. If the lines are plain objects with a public `quantity`, nothing refuses the 0, nothing
|
|
21
|
+
tells the two lines apart, and nothing stops the change once the order is placed.
|
|
22
|
+
|
|
23
|
+
::: tip The fix
|
|
24
|
+
`OrderLine` is an entity: it has its own identifier, so the second line stays the second line, and
|
|
25
|
+
a `changeQuantity` method that refuses a quantity of 0. The `Order` root decides whether the line
|
|
26
|
+
may change at all.
|
|
27
|
+
:::
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
An entity is defined by its identity, not by its values: two lines with the same product and
|
|
32
|
+
quantity are still two lines. It lives inside an [aggregate](./aggregates.md), and only the root
|
|
33
|
+
of that aggregate holds it and calls it.
|
|
34
|
+
|
|
35
|
+
The rules are split between the two:
|
|
36
|
+
|
|
37
|
+
<div class="al-cards al-cards-2">
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>The entity keeps its own rules</span>"A quantity is at least 1" only involves the line: <code>OrderLine.changeQuantity</code> checks it.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>The root keeps the shared rules</span>"No change once placed" involves the order: <code>Order.changeLineQuantity</code> checks it, then finds the line and calls it.</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
changeQuantity(quantity: number): Result<void, InvalidQuantity> {
|
|
44
|
+
if (quantity <= 0) {
|
|
45
|
+
return err(new InvalidQuantity({ quantity }));
|
|
46
|
+
}
|
|
47
|
+
this.currentQuantity = quantity;
|
|
48
|
+
return ok();
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Where it fits
|
|
53
|
+
|
|
54
|
+
A [command handler](../application/command-handlers.md) never reaches an entity. It calls the root,
|
|
55
|
+
which calls the entity. The entity is saved inside the snapshot of its aggregate.
|
|
56
|
+
|
|
57
|
+
<div class="al-diagram">
|
|
58
|
+
<svg viewBox="0 0 680 150" role="img" aria-label="A command handler calls the Order root, which calls the OrderLine entity. The order line is saved inside the snapshot of the order.">
|
|
59
|
+
<defs>
|
|
60
|
+
<marker id="entity-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
61
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
62
|
+
</marker>
|
|
63
|
+
</defs>
|
|
64
|
+
<rect class="box" x="8" y="24" width="160" height="56" rx="8" />
|
|
65
|
+
<text class="label" x="88" y="48" text-anchor="middle">Command handler</text>
|
|
66
|
+
<text class="note" x="88" y="68" text-anchor="middle">calls the root</text>
|
|
67
|
+
<path class="link" d="M 168 52 L 258 52" marker-end="url(#entity-flow-arrow)" />
|
|
68
|
+
<rect class="box" x="260" y="24" width="160" height="56" rx="8" />
|
|
69
|
+
<text class="label" x="340" y="48" text-anchor="middle">Order</text>
|
|
70
|
+
<text class="note" x="340" y="68" text-anchor="middle">changeLineQuantity()</text>
|
|
71
|
+
<path class="link" d="M 420 52 L 510 52" marker-end="url(#entity-flow-arrow)" />
|
|
72
|
+
<rect class="boundary" x="512" y="24" width="160" height="56" rx="8" />
|
|
73
|
+
<text class="label" x="592" y="48" text-anchor="middle">OrderLine</text>
|
|
74
|
+
<text class="note" x="592" y="68" text-anchor="middle">changeQuantity()</text>
|
|
75
|
+
<text class="note" x="340" y="122" text-anchor="middle">saved as part of Order.toSnapshot()</text>
|
|
76
|
+
</svg>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
::: tip
|
|
80
|
+
Code outside the aggregate never holds an entity. It names a line by its `OrderLineId`, and the root
|
|
81
|
+
finds it.
|
|
82
|
+
:::
|
|
83
|
+
|
|
84
|
+
## API
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { Entity } from "@alveolus/core";
|
|
88
|
+
// or: import { Entity } from "@alveolus/core/entities";
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### Type parameters
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
abstract class Entity<
|
|
95
|
+
Id extends AnyIdentifier,
|
|
96
|
+
Snapshot extends AnySnapshot = AnySnapshot,
|
|
97
|
+
> { … }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
| Parameter | What it is | Constraint |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `Id` | The [identifier](./value-objects.md#identifier) of the entity. | extends `Identifier` |
|
|
103
|
+
| `Snapshot` | The plain data its state is saved as. | a `type` of plain data; any by default |
|
|
104
|
+
|
|
105
|
+
A snapshot holds only `SnapshotValue`s: strings, numbers, booleans, `null`, `bigint`, `Date`, and
|
|
106
|
+
arrays or objects of them. `AnySnapshot` is the type of any snapshot, and `AnyEntity` the type of
|
|
107
|
+
any entity.
|
|
108
|
+
|
|
109
|
+
### `constructor(id)` <Badge type="info" text="protected" /> <Badge type="tip" text="you call it" />
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
protected constructor(id: Id)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Stores the identifier. Declare your own constructor `private` and call `super(id)` from it: only
|
|
116
|
+
your factories and `fromSnapshot` create the entity.
|
|
117
|
+
|
|
118
|
+
### `toSnapshot()` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" />
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
abstract toSnapshot(): Snapshot
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Returns the state as plain data, for the snapshot of the aggregate.
|
|
125
|
+
|
|
126
|
+
### `fromSnapshot(snapshot)` <Badge type="info" text="static · convention" /> <Badge type="tip" text="you implement it" />
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
static fromSnapshot(snapshot: OrderLineSnapshot): OrderLine
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Rebuilds the entity from its snapshot, through the private constructor, without checking rules.
|
|
133
|
+
Not declared by `Entity`: TypeScript has no abstract static methods.
|
|
134
|
+
|
|
135
|
+
### `equals(other)` <Badge type="tip" text="called by the root" />
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
equals(other: AnyEntity): boolean
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`true` when `other` is the same object, or an instance of the same class with an equal
|
|
142
|
+
identifier, whatever the other attributes.
|
|
143
|
+
|
|
144
|
+
### `id` <Badge type="info" text="readonly" /> <Badge type="tip" text="read by anyone" />
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
readonly id: Id
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The identifier given to the constructor.
|
|
151
|
+
|
|
152
|
+
::: warning Caveats
|
|
153
|
+
- `equals` requires the same concrete class: an entity is never equal to an instance of a subclass
|
|
154
|
+
with the same identifier.
|
|
155
|
+
- Declare the snapshot with `type`, not `interface`: an interface does not satisfy `AnySnapshot`.
|
|
156
|
+
- TypeScript has no abstract static methods: the compiler does not check that `fromSnapshot`
|
|
157
|
+
exists.
|
|
158
|
+
- An entity has no `record`: only the root of the aggregate records domain events.
|
|
159
|
+
:::
|
|
160
|
+
|
|
161
|
+
## Usage
|
|
162
|
+
|
|
163
|
+
Build `OrderLine`, an entity inside the `Order` aggregate, one idea at a time. Each step shows the whole file it changes: added lines are highlighted, replaced lines are struck out.
|
|
164
|
+
|
|
165
|
+
<div class="al-cards">
|
|
166
|
+
<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 entity its identifier.</div>
|
|
167
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-it">Declare it</a></span>One class, one way in.</div>
|
|
168
|
+
<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>A snapshot inside the order's.</div>
|
|
169
|
+
<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>
|
|
170
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-expose-reads-as-getters">Expose reads as getters</a></span>Read without changing.</div>
|
|
171
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-use-it-from-its-aggregate">Use it from its aggregate</a></span>Only the root holds it.</div>
|
|
172
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">7</span><a href="#_7-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
### 1. Name it
|
|
176
|
+
|
|
177
|
+
An entity is known by its identifier: declare `OrderLineId` as an
|
|
178
|
+
[identifier](./value-objects.md#identifier), in `domain/value-objects/order-line-id.identifier.ts`.
|
|
179
|
+
|
|
180
|
+
### 2. Declare it
|
|
181
|
+
|
|
182
|
+
So that a line is only created through the rules of its aggregate, the constructor is private and
|
|
183
|
+
`create` is the only way in. As for an aggregate, `id` is passed to `super`, which stores it as
|
|
184
|
+
the public, read-only identifier. `currentQuantity` changes, so it is the one field that is not
|
|
185
|
+
`readonly`.
|
|
186
|
+
|
|
187
|
+
```ts [src/ordering/domain/entities/order-line.entity.ts]
|
|
188
|
+
import { Entity } from "@alveolus/core";
|
|
189
|
+
|
|
190
|
+
import { OrderLineId } from "../value-objects/order-line-id.identifier";
|
|
191
|
+
import { ProductId } from "../value-objects/product-id.identifier";
|
|
192
|
+
|
|
193
|
+
export class OrderLine extends Entity<OrderLineId> {
|
|
194
|
+
private constructor(
|
|
195
|
+
id: OrderLineId,
|
|
196
|
+
private readonly productId: ProductId,
|
|
197
|
+
private currentQuantity: number,
|
|
198
|
+
) {
|
|
199
|
+
super(id);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
static create(id: OrderLineId, productId: ProductId): OrderLine {
|
|
203
|
+
return new OrderLine(id, productId, 1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
TypeScript now asks for `toSnapshot()`: the next step adds it.
|
|
209
|
+
|
|
210
|
+
### 3. Make it storable
|
|
211
|
+
|
|
212
|
+
The state is private, so the order saves it as a snapshot: plain, read-only data declared with
|
|
213
|
+
`type`. `fromSnapshot` rebuilds the line without checking any rule.
|
|
214
|
+
|
|
215
|
+
```ts [src/ordering/domain/entities/order-line.entity.ts]
|
|
216
|
+
import { Entity } from "@alveolus/core";
|
|
217
|
+
|
|
218
|
+
import { OrderLineId } from "../value-objects/order-line-id.identifier";
|
|
219
|
+
import { ProductId } from "../value-objects/product-id.identifier";
|
|
220
|
+
|
|
221
|
+
export type OrderLineSnapshot = { // [!code ++]
|
|
222
|
+
readonly id: string; // [!code ++]
|
|
223
|
+
readonly productId: string; // [!code ++]
|
|
224
|
+
readonly quantity: number; // [!code ++]
|
|
225
|
+
}; // [!code ++]
|
|
226
|
+
|
|
227
|
+
export class OrderLine extends Entity<OrderLineId> { // [!code --]
|
|
228
|
+
export class OrderLine extends Entity<OrderLineId, OrderLineSnapshot> { // [!code ++]
|
|
229
|
+
private constructor(
|
|
230
|
+
id: OrderLineId,
|
|
231
|
+
private readonly productId: ProductId,
|
|
232
|
+
private currentQuantity: number,
|
|
233
|
+
) {
|
|
234
|
+
super(id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
static create(id: OrderLineId, productId: ProductId): OrderLine {
|
|
238
|
+
return new OrderLine(id, productId, 1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
static fromSnapshot(snapshot: OrderLineSnapshot): OrderLine { // [!code ++]
|
|
242
|
+
return new OrderLine( // [!code ++]
|
|
243
|
+
new OrderLineId(snapshot.id), // [!code ++]
|
|
244
|
+
new ProductId(snapshot.productId), // [!code ++]
|
|
245
|
+
snapshot.quantity, // [!code ++]
|
|
246
|
+
); // [!code ++]
|
|
247
|
+
} // [!code ++]
|
|
248
|
+
|
|
249
|
+
toSnapshot(): OrderLineSnapshot { // [!code ++]
|
|
250
|
+
return { // [!code ++]
|
|
251
|
+
id: this.id.value, // [!code ++]
|
|
252
|
+
productId: this.productId.value, // [!code ++]
|
|
253
|
+
quantity: this.currentQuantity, // [!code ++]
|
|
254
|
+
}; // [!code ++]
|
|
255
|
+
} // [!code ++]
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### 4. Change it through a method
|
|
260
|
+
|
|
261
|
+
So that no caller can skip a rule, the quantity changes through a method named after what the
|
|
262
|
+
business does. A wrong quantity is returned in a `Result`, and nothing changes.
|
|
263
|
+
|
|
264
|
+
```ts [src/ordering/domain/entities/order-line.entity.ts]
|
|
265
|
+
import { Entity } from "@alveolus/core"; // [!code --]
|
|
266
|
+
import { Entity, err, ok, type Result } from "@alveolus/core"; // [!code ++]
|
|
267
|
+
|
|
268
|
+
import { InvalidQuantity } from "../errors/invalid-quantity.error"; // [!code ++]
|
|
269
|
+
import { OrderLineId } from "../value-objects/order-line-id.identifier";
|
|
270
|
+
import { ProductId } from "../value-objects/product-id.identifier";
|
|
271
|
+
|
|
272
|
+
export type OrderLineSnapshot = {
|
|
273
|
+
readonly id: string;
|
|
274
|
+
readonly productId: string;
|
|
275
|
+
readonly quantity: number;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
export class OrderLine extends Entity<OrderLineId, OrderLineSnapshot> {
|
|
279
|
+
private constructor(
|
|
280
|
+
id: OrderLineId,
|
|
281
|
+
private readonly productId: ProductId,
|
|
282
|
+
private currentQuantity: number,
|
|
283
|
+
) {
|
|
284
|
+
super(id);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
static create(id: OrderLineId, productId: ProductId): OrderLine {
|
|
288
|
+
return new OrderLine(id, productId, 1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
static fromSnapshot(snapshot: OrderLineSnapshot): OrderLine {
|
|
292
|
+
return new OrderLine(
|
|
293
|
+
new OrderLineId(snapshot.id),
|
|
294
|
+
new ProductId(snapshot.productId),
|
|
295
|
+
snapshot.quantity,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
changeQuantity(quantity: number): Result<void, InvalidQuantity> { // [!code ++]
|
|
300
|
+
if (quantity <= 0) { // [!code ++]
|
|
301
|
+
return err(new InvalidQuantity({ quantity })); // [!code ++]
|
|
302
|
+
} // [!code ++]
|
|
303
|
+
this.currentQuantity = quantity; // [!code ++]
|
|
304
|
+
return ok(); // [!code ++]
|
|
305
|
+
} // [!code ++]
|
|
306
|
+
|
|
307
|
+
toSnapshot(): OrderLineSnapshot {
|
|
308
|
+
return {
|
|
309
|
+
id: this.id.value,
|
|
310
|
+
productId: this.productId.value,
|
|
311
|
+
quantity: this.currentQuantity,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### 5. Expose reads as getters
|
|
318
|
+
|
|
319
|
+
Callers need to read the quantity without changing it. Reads are getters: a public method must
|
|
320
|
+
return a `Result`, a getter does not.
|
|
321
|
+
|
|
322
|
+
```ts [src/ordering/domain/entities/order-line.entity.ts]
|
|
323
|
+
import { Entity, err, ok, type Result } from "@alveolus/core";
|
|
324
|
+
|
|
325
|
+
import { InvalidQuantity } from "../errors/invalid-quantity.error";
|
|
326
|
+
import { OrderLineId } from "../value-objects/order-line-id.identifier";
|
|
327
|
+
import { ProductId } from "../value-objects/product-id.identifier";
|
|
328
|
+
|
|
329
|
+
export type OrderLineSnapshot = {
|
|
330
|
+
readonly id: string;
|
|
331
|
+
readonly productId: string;
|
|
332
|
+
readonly quantity: number;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
export class OrderLine extends Entity<OrderLineId, OrderLineSnapshot> {
|
|
336
|
+
private constructor(
|
|
337
|
+
id: OrderLineId,
|
|
338
|
+
private readonly productId: ProductId,
|
|
339
|
+
private currentQuantity: number,
|
|
340
|
+
) {
|
|
341
|
+
super(id);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
static create(id: OrderLineId, productId: ProductId): OrderLine {
|
|
345
|
+
return new OrderLine(id, productId, 1);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
static fromSnapshot(snapshot: OrderLineSnapshot): OrderLine {
|
|
349
|
+
return new OrderLine(
|
|
350
|
+
new OrderLineId(snapshot.id),
|
|
351
|
+
new ProductId(snapshot.productId),
|
|
352
|
+
snapshot.quantity,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
get quantity(): number { // [!code ++]
|
|
357
|
+
return this.currentQuantity; // [!code ++]
|
|
358
|
+
} // [!code ++]
|
|
359
|
+
|
|
360
|
+
changeQuantity(quantity: number): Result<void, InvalidQuantity> {
|
|
361
|
+
if (quantity <= 0) {
|
|
362
|
+
return err(new InvalidQuantity({ quantity }));
|
|
363
|
+
}
|
|
364
|
+
this.currentQuantity = quantity;
|
|
365
|
+
return ok();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
toSnapshot(): OrderLineSnapshot {
|
|
369
|
+
return {
|
|
370
|
+
id: this.id.value,
|
|
371
|
+
productId: this.productId.value,
|
|
372
|
+
quantity: this.currentQuantity,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### 6. Use it from its aggregate
|
|
379
|
+
|
|
380
|
+
Code outside the aggregate never holds a line: the [`Order`](./aggregates.md) creates it, changes
|
|
381
|
+
it, and saves it inside its own snapshot with `line.toSnapshot()`.
|
|
382
|
+
|
|
383
|
+
```ts [src/ordering/domain/aggregates/order.aggregate.ts]
|
|
384
|
+
addLine(
|
|
385
|
+
lineId: OrderLineId,
|
|
386
|
+
productId: ProductId,
|
|
387
|
+
): Result<void, OrderAlreadyPlaced> {
|
|
388
|
+
if (this.isPlaced) {
|
|
389
|
+
return err(new OrderAlreadyPlaced());
|
|
390
|
+
}
|
|
391
|
+
this.lines.push(OrderLine.create(lineId, productId));
|
|
392
|
+
return ok();
|
|
393
|
+
}
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### 7. Check it
|
|
397
|
+
|
|
398
|
+
Run the checks. Three rules keep the entity the way it is now:
|
|
399
|
+
|
|
400
|
+
```sh
|
|
401
|
+
npx alveolus arch check
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
<div class="al-cards">
|
|
405
|
+
<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>; reads are getters.</div>
|
|
406
|
+
<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>ProductId</code>, never a <code>Product</code>.</div>
|
|
407
|
+
<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/entities/*.entity.ts</code>.</div>
|
|
408
|
+
</div>
|
|
409
|
+
|
|
410
|
+
A setter added later is reported:
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
src/ordering/domain/entities/order-line.entity.ts
|
|
414
|
+
38 error tactical/no-thrown-failure: OrderLine.setQuantity must return
|
|
415
|
+
a Result: expose reads as getters and return business failures
|
|
416
|
+
as values.
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
## Troubleshooting
|
|
420
|
+
|
|
421
|
+
**`Type 'OrderLineSnapshot' does not satisfy the constraint 'AnySnapshot'`**: the snapshot is an
|
|
422
|
+
`interface`, or a field holds an identifier or a value object. Declare it with `type` and write
|
|
423
|
+
raw values (`productId: string`).
|
|
424
|
+
|
|
425
|
+
## See also
|
|
426
|
+
|
|
427
|
+
- [Aggregates](./aggregates.md), the root that owns entities
|
|
428
|
+
- [Value objects](./value-objects.md), for identifiers and things without identity
|
|
429
|
+
- [Domain errors](./domain-errors.md), what its methods return
|
|
430
|
+
- 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)
|
|
431
|
+
- Vaughn Vernon, *Implementing Domain-Driven Design*, chapter 5, "Entities"
|