@alveolus/arch 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-P5PwH9OE.mjs → docs-DsQHpTtV.mjs} +190 -5
- package/dist/docs-DsQHpTtV.mjs.map +1 -0
- package/dist/index.d.mts +44 -2
- 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-P5PwH9OE.mjs.map +0 -1
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Command handlers in CQRS with TypeScript: run one use case that changes the system by loading an aggregate, calling one of its methods and saving it."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Command handlers
|
|
6
|
+
|
|
7
|
+
A command handler runs one use case that changes the system: it loads an aggregate, calls one of its
|
|
8
|
+
methods and saves it.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Application</dd>
|
|
12
|
+
<dt>File</dt><dd><code>application/commands/place-order.command.ts</code></dd>
|
|
13
|
+
<dt>Extends</dt><dd><a href="#api"><code>CommandHandler<Input, Output, Error></code></a></dd>
|
|
14
|
+
<dt>Called by</dt><dd>Driving adapters: controllers, message consumers, scripts</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-foreign-command-dependency"><code>tactical/no-foreign-command-dependency</code></a>, <a href="/rules/layers/no-outward-import"><code>layers/no-outward-import</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Placing an order takes more than calling `order.place()`: load the order, get an id and the time,
|
|
21
|
+
save it, hand over its events. If the HTTP controller does all that, the message consumer and the
|
|
22
|
+
admin script do it again, each a little differently. Sooner or later one of them forgets to save
|
|
23
|
+
the events, or checks a business rule the aggregate never sees.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
A command handler does these steps once, in a plain class that knows no framework. Every entry
|
|
27
|
+
point calls it. The handler coordinates and the [aggregate](../domain/aggregates.md) decides, so the
|
|
28
|
+
rules stay in one place.
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
A command handler receives a command, the data of one request, and returns a
|
|
34
|
+
[`Result`](../utilities/result.md). In between, it follows four steps:
|
|
35
|
+
|
|
36
|
+
<div class="al-cards al-cards-2">
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Load</span>Find the aggregate through its <a href="/core/domain/repositories">command repository</a>. When it is missing, return a domain error.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Call</span>Call one business method, with the ids and the date from the <code>IdGenerator</code> and <code>Clock</code> <a href="/core/domain/ports">ports</a>. A failure is returned as is.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Save</span>Store the aggregate through the same repository.</div>
|
|
40
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span>Hand over the events</span>Translate the recorded events and add them to the <a href="/core/application/outbox">outbox</a>, in the same <a href="/core/application/unit-of-work">unit of work</a>.</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
async handle({
|
|
45
|
+
orderId,
|
|
46
|
+
}: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
|
|
47
|
+
const order = await this.orders.findById(new OrderId(orderId));
|
|
48
|
+
if (order === undefined) {
|
|
49
|
+
return err(new OrderNotFound({ orderId }));
|
|
50
|
+
}
|
|
51
|
+
const placed = order.place(this.ids.next(), this.clock.now());
|
|
52
|
+
if (!placed.ok) {
|
|
53
|
+
return placed;
|
|
54
|
+
}
|
|
55
|
+
await this.orders.save(order);
|
|
56
|
+
return ok();
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Where it fits
|
|
61
|
+
|
|
62
|
+
The command handler sits between the outside world and the domain. A driving adapter builds the
|
|
63
|
+
command and calls `handle`; the handler talks to the domain only through repositories, ports and
|
|
64
|
+
the aggregate.
|
|
65
|
+
|
|
66
|
+
<div class="al-diagram">
|
|
67
|
+
<svg viewBox="0 0 680 300" role="img" aria-label="A controller calls 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.">
|
|
68
|
+
<defs>
|
|
69
|
+
<marker id="command-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
70
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
71
|
+
</marker>
|
|
72
|
+
</defs>
|
|
73
|
+
<rect class="box" x="8" y="122" width="130" height="56" rx="8" />
|
|
74
|
+
<text class="label" x="73" y="146" text-anchor="middle">Controller</text>
|
|
75
|
+
<text class="note" x="73" y="166" text-anchor="middle">driving adapter</text>
|
|
76
|
+
<path class="link" d="M 138 150 L 178 150" marker-end="url(#command-flow-arrow)" />
|
|
77
|
+
<rect class="boundary" x="180" y="122" width="180" height="56" rx="8" />
|
|
78
|
+
<text class="label" x="270" y="146" text-anchor="middle">PlaceOrderHandler</text>
|
|
79
|
+
<text class="note" x="270" y="166" text-anchor="middle">this page</text>
|
|
80
|
+
<text class="note" x="270" y="204" text-anchor="middle">one unit of work</text>
|
|
81
|
+
<rect class="box" x="440" y="24" width="232" height="48" rx="8" />
|
|
82
|
+
<text class="label" x="556" y="44" text-anchor="middle">1 · orders.findById(id)</text>
|
|
83
|
+
<text class="note" x="556" y="62" text-anchor="middle">command repository</text>
|
|
84
|
+
<rect class="box" x="440" y="92" width="232" height="48" rx="8" />
|
|
85
|
+
<text class="label" x="556" y="112" text-anchor="middle">2 · order.place(…)</text>
|
|
86
|
+
<text class="note" x="556" y="130" text-anchor="middle">the aggregate decides</text>
|
|
87
|
+
<rect class="box" x="440" y="160" width="232" height="48" rx="8" />
|
|
88
|
+
<text class="label" x="556" y="180" text-anchor="middle">3 · orders.save(order)</text>
|
|
89
|
+
<text class="note" x="556" y="198" text-anchor="middle">command repository</text>
|
|
90
|
+
<rect class="box" x="440" y="228" width="232" height="48" rx="8" />
|
|
91
|
+
<text class="label" x="556" y="248" text-anchor="middle">4 · outbox.add(events)</text>
|
|
92
|
+
<text class="note" x="556" y="266" text-anchor="middle">translated events</text>
|
|
93
|
+
<path class="link" d="M 360 150 L 438 48" marker-end="url(#command-flow-arrow)" />
|
|
94
|
+
<path class="link" d="M 360 150 L 438 116" marker-end="url(#command-flow-arrow)" />
|
|
95
|
+
<path class="link" d="M 360 150 L 438 184" marker-end="url(#command-flow-arrow)" />
|
|
96
|
+
<path class="link" d="M 360 150 L 438 252" marker-end="url(#command-flow-arrow)" />
|
|
97
|
+
</svg>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
::: tip
|
|
101
|
+
The controller turns HTTP into a command and a `Result` into a response. The handler turns a command
|
|
102
|
+
into calls to the domain. Neither holds a business rule.
|
|
103
|
+
:::
|
|
104
|
+
|
|
105
|
+
## API
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
import { CommandHandler } from "@alveolus/core";
|
|
109
|
+
// or: import { CommandHandler } from "@alveolus/core/command-handlers";
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Type parameters
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
abstract class CommandHandler<
|
|
116
|
+
Input,
|
|
117
|
+
Output = void,
|
|
118
|
+
Error extends AnyDomainError = never,
|
|
119
|
+
> { … }
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
| Parameter | What it is | Constraint |
|
|
123
|
+
| --- | --- | --- |
|
|
124
|
+
| `Input` | The command: the data the handler needs. | any type |
|
|
125
|
+
| `Output` | What a success returns, such as the id of what was created. | `void` by default |
|
|
126
|
+
| `Error` | The union of the [domain errors](../domain/domain-errors.md) it may return. | extends `DomainError`; `never` by default |
|
|
127
|
+
|
|
128
|
+
### `constructor(…)` <Badge type="tip" text="you implement it" />
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
constructor(
|
|
132
|
+
private readonly orders: Orders,
|
|
133
|
+
private readonly clock: Clock,
|
|
134
|
+
private readonly ids: IdGenerator,
|
|
135
|
+
) {
|
|
136
|
+
super();
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`CommandHandler` declares no constructor: yours takes the dependencies as abstract classes, such
|
|
141
|
+
as repositories, ports, the unit of work and the outbox, and calls `super()`.
|
|
142
|
+
|
|
143
|
+
### `handle(command)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by a driving adapter" />
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
abstract handle(command: Input): Promise<Result<Output, Error>>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Runs the use case and returns its outcome. The driving adapter that calls it turns the `Result`
|
|
150
|
+
into a response.
|
|
151
|
+
|
|
152
|
+
::: warning Caveats
|
|
153
|
+
- `Error` only accepts `DomainError` subclasses. A technical failure, such as a lost database
|
|
154
|
+
connection, is thrown and handled like any other exception.
|
|
155
|
+
- Alveolus provides no bus and no container: wire handlers in the composition root, by hand or
|
|
156
|
+
with the container of your framework. See [Integrations](../../integrations/index.md).
|
|
157
|
+
- The events recorded by the aggregate stay on it after `save`: hand them over through the
|
|
158
|
+
[outbox](./outbox.md). A handler never receives an `EventPublisher`: the relay publishes.
|
|
159
|
+
- A command changes one aggregate. When a second one must change too, it reacts to the event of
|
|
160
|
+
the first, in its own transaction (Vernon, *DDD Distilled*, chapter 5). The rules do not check
|
|
161
|
+
this: a handler that receives two command repositories is a review item.
|
|
162
|
+
:::
|
|
163
|
+
|
|
164
|
+
## Usage
|
|
165
|
+
|
|
166
|
+
Build the handler that places an order, one responsibility at a time. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
|
|
167
|
+
|
|
168
|
+
<div class="al-cards">
|
|
169
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-declare-the-command">Declare the command</a></span>Say what the caller provides and what can fail.</div>
|
|
170
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-handler">Declare the handler</a></span>One class, one use case.</div>
|
|
171
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-call-the-aggregate">Call the aggregate</a></span>Let the aggregate decide.</div>
|
|
172
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-save-it">Save it</a></span>Store the changed aggregate.</div>
|
|
173
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-hand-over-its-events">Hand over its events</a></span>Translate and add them to the outbox.</div>
|
|
174
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-make-it-atomic">Make it atomic</a></span>One transaction for the change and its events.</div>
|
|
175
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">7</span><a href="#_7-call-it-from-a-driving-adapter">Call it from a driving adapter</a></span>Turn the Result into a response.</div>
|
|
176
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">8</span><a href="#_8-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
### 1. Declare the command
|
|
180
|
+
|
|
181
|
+
So that a driving adapter knows what to provide and what to handle, the command is a plain type named in the imperative, next to the union of every failure it can return.
|
|
182
|
+
|
|
183
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
184
|
+
import type { EmptyOrder } from "../../domain/errors/empty-order.error";
|
|
185
|
+
import type {
|
|
186
|
+
OrderAlreadyPlaced,
|
|
187
|
+
} from "../../domain/errors/order-already-placed.error";
|
|
188
|
+
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
|
|
189
|
+
|
|
190
|
+
export interface PlaceOrder {
|
|
191
|
+
readonly orderId: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export type PlaceOrderError =
|
|
195
|
+
| OrderNotFound
|
|
196
|
+
| OrderAlreadyPlaced
|
|
197
|
+
| EmptyOrder;
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 2. Declare the handler
|
|
201
|
+
|
|
202
|
+
The handler extends `CommandHandler` with the command, its output and its errors. It receives its dependencies as abstract classes, so that any framework can build it, and loads the aggregate through its repository. A missing order is a failure it declares, not an exception.
|
|
203
|
+
|
|
204
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
205
|
+
import { CommandHandler, err, ok, type Result } from "@alveolus/core"; // [!code ++]
|
|
206
|
+
|
|
207
|
+
import type { EmptyOrder } from "../../domain/errors/empty-order.error";
|
|
208
|
+
import type {
|
|
209
|
+
OrderAlreadyPlaced,
|
|
210
|
+
} from "../../domain/errors/order-already-placed.error";
|
|
211
|
+
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
|
|
212
|
+
import { Orders } from "../../domain/repositories/orders.repository"; // [!code ++]
|
|
213
|
+
import { OrderId } from "../../domain/value-objects/order-id.identifier"; // [!code ++]
|
|
214
|
+
|
|
215
|
+
export interface PlaceOrder {
|
|
216
|
+
readonly orderId: string;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export type PlaceOrderError =
|
|
220
|
+
| OrderNotFound
|
|
221
|
+
| OrderAlreadyPlaced
|
|
222
|
+
| EmptyOrder;
|
|
223
|
+
|
|
224
|
+
export class PlaceOrderHandler extends CommandHandler< // [!code ++]
|
|
225
|
+
PlaceOrder, // [!code ++]
|
|
226
|
+
void, // [!code ++]
|
|
227
|
+
PlaceOrderError // [!code ++]
|
|
228
|
+
> { // [!code ++]
|
|
229
|
+
constructor( // [!code ++]
|
|
230
|
+
private readonly orders: Orders, // [!code ++]
|
|
231
|
+
) { // [!code ++]
|
|
232
|
+
super(); // [!code ++]
|
|
233
|
+
} // [!code ++]
|
|
234
|
+
|
|
235
|
+
async handle({ // [!code ++]
|
|
236
|
+
orderId, // [!code ++]
|
|
237
|
+
}: PlaceOrder): Promise<Result<void, PlaceOrderError>> { // [!code ++]
|
|
238
|
+
const order = await this.orders.findById( // [!code ++]
|
|
239
|
+
new OrderId(orderId), // [!code ++]
|
|
240
|
+
); // [!code ++]
|
|
241
|
+
if (order === undefined) { // [!code ++]
|
|
242
|
+
return err(new OrderNotFound({ orderId })); // [!code ++]
|
|
243
|
+
} // [!code ++]
|
|
244
|
+
return ok(); // [!code ++]
|
|
245
|
+
} // [!code ++]
|
|
246
|
+
} // [!code ++]
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### 3. Call the aggregate
|
|
250
|
+
|
|
251
|
+
The handler decides nothing: it calls one business method and returns its failure as is. The event id and the date come from the `Clock` and `IdGenerator` [ports](../domain/ports.md), so the aggregate never reads them itself.
|
|
252
|
+
|
|
253
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
254
|
+
import { CommandHandler, err, ok, type Result } from "@alveolus/core"; // [!code --]
|
|
255
|
+
import { // [!code ++]
|
|
256
|
+
Clock, // [!code ++]
|
|
257
|
+
CommandHandler, // [!code ++]
|
|
258
|
+
err, // [!code ++]
|
|
259
|
+
IdGenerator, // [!code ++]
|
|
260
|
+
ok, // [!code ++]
|
|
261
|
+
type Result, // [!code ++]
|
|
262
|
+
} from "@alveolus/core"; // [!code ++]
|
|
263
|
+
|
|
264
|
+
import type { EmptyOrder } from "../../domain/errors/empty-order.error";
|
|
265
|
+
import type {
|
|
266
|
+
OrderAlreadyPlaced,
|
|
267
|
+
} from "../../domain/errors/order-already-placed.error";
|
|
268
|
+
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
|
|
269
|
+
import { Orders } from "../../domain/repositories/orders.repository";
|
|
270
|
+
import { OrderId } from "../../domain/value-objects/order-id.identifier";
|
|
271
|
+
|
|
272
|
+
export interface PlaceOrder {
|
|
273
|
+
readonly orderId: string;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export type PlaceOrderError =
|
|
277
|
+
| OrderNotFound
|
|
278
|
+
| OrderAlreadyPlaced
|
|
279
|
+
| EmptyOrder;
|
|
280
|
+
|
|
281
|
+
export class PlaceOrderHandler extends CommandHandler<
|
|
282
|
+
PlaceOrder,
|
|
283
|
+
void,
|
|
284
|
+
PlaceOrderError
|
|
285
|
+
> {
|
|
286
|
+
constructor(
|
|
287
|
+
private readonly orders: Orders,
|
|
288
|
+
private readonly clock: Clock, // [!code ++]
|
|
289
|
+
private readonly ids: IdGenerator, // [!code ++]
|
|
290
|
+
) {
|
|
291
|
+
super();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async handle({
|
|
295
|
+
orderId,
|
|
296
|
+
}: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
|
|
297
|
+
const order = await this.orders.findById(
|
|
298
|
+
new OrderId(orderId),
|
|
299
|
+
);
|
|
300
|
+
if (order === undefined) {
|
|
301
|
+
return err(new OrderNotFound({ orderId }));
|
|
302
|
+
}
|
|
303
|
+
const placed = order.place( // [!code ++]
|
|
304
|
+
this.ids.next(), // [!code ++]
|
|
305
|
+
this.clock.now(), // [!code ++]
|
|
306
|
+
); // [!code ++]
|
|
307
|
+
if (!placed.ok) { // [!code ++]
|
|
308
|
+
return placed; // [!code ++]
|
|
309
|
+
} // [!code ++]
|
|
310
|
+
return ok();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
### 4. Save it
|
|
316
|
+
|
|
317
|
+
Only a successful change is saved: when `place` fails, the handler has already returned and the order stays as it was in storage.
|
|
318
|
+
|
|
319
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
320
|
+
import {
|
|
321
|
+
Clock,
|
|
322
|
+
CommandHandler,
|
|
323
|
+
err,
|
|
324
|
+
IdGenerator,
|
|
325
|
+
ok,
|
|
326
|
+
type Result,
|
|
327
|
+
} from "@alveolus/core";
|
|
328
|
+
|
|
329
|
+
import type { EmptyOrder } from "../../domain/errors/empty-order.error";
|
|
330
|
+
import type {
|
|
331
|
+
OrderAlreadyPlaced,
|
|
332
|
+
} from "../../domain/errors/order-already-placed.error";
|
|
333
|
+
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
|
|
334
|
+
import { Orders } from "../../domain/repositories/orders.repository";
|
|
335
|
+
import { OrderId } from "../../domain/value-objects/order-id.identifier";
|
|
336
|
+
|
|
337
|
+
export interface PlaceOrder {
|
|
338
|
+
readonly orderId: string;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
export type PlaceOrderError =
|
|
342
|
+
| OrderNotFound
|
|
343
|
+
| OrderAlreadyPlaced
|
|
344
|
+
| EmptyOrder;
|
|
345
|
+
|
|
346
|
+
export class PlaceOrderHandler extends CommandHandler<
|
|
347
|
+
PlaceOrder,
|
|
348
|
+
void,
|
|
349
|
+
PlaceOrderError
|
|
350
|
+
> {
|
|
351
|
+
constructor(
|
|
352
|
+
private readonly orders: Orders,
|
|
353
|
+
private readonly clock: Clock,
|
|
354
|
+
private readonly ids: IdGenerator,
|
|
355
|
+
) {
|
|
356
|
+
super();
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async handle({
|
|
360
|
+
orderId,
|
|
361
|
+
}: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
|
|
362
|
+
const order = await this.orders.findById(
|
|
363
|
+
new OrderId(orderId),
|
|
364
|
+
);
|
|
365
|
+
if (order === undefined) {
|
|
366
|
+
return err(new OrderNotFound({ orderId }));
|
|
367
|
+
}
|
|
368
|
+
const placed = order.place(
|
|
369
|
+
this.ids.next(),
|
|
370
|
+
this.clock.now(),
|
|
371
|
+
);
|
|
372
|
+
if (!placed.ok) {
|
|
373
|
+
return placed;
|
|
374
|
+
}
|
|
375
|
+
await this.orders.save(order); // [!code ++]
|
|
376
|
+
return ok();
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### 5. Hand over its events
|
|
382
|
+
|
|
383
|
+
Other contexts must learn that the order was placed. After saving, the handler pulls the recorded events, translates each one into the published language and adds them to the [outbox](./outbox.md).
|
|
384
|
+
|
|
385
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
386
|
+
import {
|
|
387
|
+
Clock,
|
|
388
|
+
CommandHandler,
|
|
389
|
+
err,
|
|
390
|
+
IdGenerator,
|
|
391
|
+
ok,
|
|
392
|
+
Outbox, // [!code ++]
|
|
393
|
+
type Result,
|
|
394
|
+
} from "@alveolus/core";
|
|
395
|
+
|
|
396
|
+
import type { EmptyOrder } from "../../domain/errors/empty-order.error";
|
|
397
|
+
import type {
|
|
398
|
+
OrderAlreadyPlaced,
|
|
399
|
+
} from "../../domain/errors/order-already-placed.error";
|
|
400
|
+
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
|
|
401
|
+
import { Orders } from "../../domain/repositories/orders.repository";
|
|
402
|
+
import { OrderId } from "../../domain/value-objects/order-id.identifier";
|
|
403
|
+
import { // [!code ++]
|
|
404
|
+
OrderEventsTranslator, // [!code ++]
|
|
405
|
+
} from "../translators/order-events.translator"; // [!code ++]
|
|
406
|
+
|
|
407
|
+
export interface PlaceOrder {
|
|
408
|
+
readonly orderId: string;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export type PlaceOrderError =
|
|
412
|
+
| OrderNotFound
|
|
413
|
+
| OrderAlreadyPlaced
|
|
414
|
+
| EmptyOrder;
|
|
415
|
+
|
|
416
|
+
export class PlaceOrderHandler extends CommandHandler<
|
|
417
|
+
PlaceOrder,
|
|
418
|
+
void,
|
|
419
|
+
PlaceOrderError
|
|
420
|
+
> {
|
|
421
|
+
constructor(
|
|
422
|
+
private readonly orders: Orders,
|
|
423
|
+
private readonly outbox: Outbox, // [!code ++]
|
|
424
|
+
private readonly translator: OrderEventsTranslator, // [!code ++]
|
|
425
|
+
private readonly clock: Clock,
|
|
426
|
+
private readonly ids: IdGenerator,
|
|
427
|
+
) {
|
|
428
|
+
super();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async handle({
|
|
432
|
+
orderId,
|
|
433
|
+
}: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
|
|
434
|
+
const order = await this.orders.findById(
|
|
435
|
+
new OrderId(orderId),
|
|
436
|
+
);
|
|
437
|
+
if (order === undefined) {
|
|
438
|
+
return err(new OrderNotFound({ orderId }));
|
|
439
|
+
}
|
|
440
|
+
const placed = order.place(
|
|
441
|
+
this.ids.next(),
|
|
442
|
+
this.clock.now(),
|
|
443
|
+
);
|
|
444
|
+
if (!placed.ok) {
|
|
445
|
+
return placed;
|
|
446
|
+
}
|
|
447
|
+
await this.orders.save(order);
|
|
448
|
+
const events = order // [!code ++]
|
|
449
|
+
.pullDomainEvents() // [!code ++]
|
|
450
|
+
.map((event) => // [!code ++]
|
|
451
|
+
this.translator.translate(event, { // [!code ++]
|
|
452
|
+
correlationId: orderId, // [!code ++]
|
|
453
|
+
}), // [!code ++]
|
|
454
|
+
); // [!code ++]
|
|
455
|
+
await this.outbox.add(events); // [!code ++]
|
|
456
|
+
return ok();
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
### 6. Make it atomic
|
|
462
|
+
|
|
463
|
+
If saving the order succeeds and adding its events fails, the rest of the system never hears of it. The [unit of work](./unit-of-work.md) runs both in one transaction, and rolls back when the work returns a failure.
|
|
464
|
+
|
|
465
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
466
|
+
import {
|
|
467
|
+
Clock,
|
|
468
|
+
CommandHandler,
|
|
469
|
+
err,
|
|
470
|
+
IdGenerator,
|
|
471
|
+
ok,
|
|
472
|
+
Outbox,
|
|
473
|
+
type Result,
|
|
474
|
+
UnitOfWork, // [!code ++]
|
|
475
|
+
} from "@alveolus/core";
|
|
476
|
+
|
|
477
|
+
import type { EmptyOrder } from "../../domain/errors/empty-order.error";
|
|
478
|
+
import type {
|
|
479
|
+
OrderAlreadyPlaced,
|
|
480
|
+
} from "../../domain/errors/order-already-placed.error";
|
|
481
|
+
import { OrderNotFound } from "../../domain/errors/order-not-found.error";
|
|
482
|
+
import { Orders } from "../../domain/repositories/orders.repository";
|
|
483
|
+
import { OrderId } from "../../domain/value-objects/order-id.identifier";
|
|
484
|
+
import {
|
|
485
|
+
OrderEventsTranslator,
|
|
486
|
+
} from "../translators/order-events.translator";
|
|
487
|
+
|
|
488
|
+
export interface PlaceOrder {
|
|
489
|
+
readonly orderId: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
export type PlaceOrderError =
|
|
493
|
+
| OrderNotFound
|
|
494
|
+
| OrderAlreadyPlaced
|
|
495
|
+
| EmptyOrder;
|
|
496
|
+
|
|
497
|
+
export class PlaceOrderHandler extends CommandHandler<
|
|
498
|
+
PlaceOrder,
|
|
499
|
+
void,
|
|
500
|
+
PlaceOrderError
|
|
501
|
+
> {
|
|
502
|
+
constructor(
|
|
503
|
+
private readonly orders: Orders,
|
|
504
|
+
private readonly unitOfWork: UnitOfWork, // [!code ++]
|
|
505
|
+
private readonly outbox: Outbox,
|
|
506
|
+
private readonly translator: OrderEventsTranslator,
|
|
507
|
+
private readonly clock: Clock,
|
|
508
|
+
private readonly ids: IdGenerator,
|
|
509
|
+
) {
|
|
510
|
+
super();
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async handle({
|
|
514
|
+
orderId,
|
|
515
|
+
}: PlaceOrder): Promise<Result<void, PlaceOrderError>> {
|
|
516
|
+
const order = await this.orders.findById( // [!code --]
|
|
517
|
+
new OrderId(orderId), // [!code --]
|
|
518
|
+
); // [!code --]
|
|
519
|
+
if (order === undefined) { // [!code --]
|
|
520
|
+
return err(new OrderNotFound({ orderId })); // [!code --]
|
|
521
|
+
} // [!code --]
|
|
522
|
+
const placed = order.place( // [!code --]
|
|
523
|
+
this.ids.next(), // [!code --]
|
|
524
|
+
this.clock.now(), // [!code --]
|
|
525
|
+
); // [!code --]
|
|
526
|
+
if (!placed.ok) { // [!code --]
|
|
527
|
+
return placed; // [!code --]
|
|
528
|
+
} // [!code --]
|
|
529
|
+
await this.orders.save(order); // [!code --]
|
|
530
|
+
const events = order // [!code --]
|
|
531
|
+
.pullDomainEvents() // [!code --]
|
|
532
|
+
.map((event) => // [!code --]
|
|
533
|
+
this.translator.translate(event, { // [!code --]
|
|
534
|
+
correlationId: orderId, // [!code --]
|
|
535
|
+
}), // [!code --]
|
|
536
|
+
return this.unitOfWork.run(async () => { // [!code ++]
|
|
537
|
+
const order = await this.orders.findById( // [!code ++]
|
|
538
|
+
new OrderId(orderId), // [!code ++]
|
|
539
|
+
);
|
|
540
|
+
await this.outbox.add(events); // [!code --]
|
|
541
|
+
return ok(); // [!code --]
|
|
542
|
+
if (order === undefined) { // [!code ++]
|
|
543
|
+
return err(new OrderNotFound({ orderId })); // [!code ++]
|
|
544
|
+
} // [!code ++]
|
|
545
|
+
const placed = order.place( // [!code ++]
|
|
546
|
+
this.ids.next(), // [!code ++]
|
|
547
|
+
this.clock.now(), // [!code ++]
|
|
548
|
+
); // [!code ++]
|
|
549
|
+
if (!placed.ok) { // [!code ++]
|
|
550
|
+
return placed; // [!code ++]
|
|
551
|
+
} // [!code ++]
|
|
552
|
+
await this.orders.save(order); // [!code ++]
|
|
553
|
+
const events = order // [!code ++]
|
|
554
|
+
.pullDomainEvents() // [!code ++]
|
|
555
|
+
.map((event) => // [!code ++]
|
|
556
|
+
this.translator.translate(event, { // [!code ++]
|
|
557
|
+
correlationId: orderId, // [!code ++]
|
|
558
|
+
}), // [!code ++]
|
|
559
|
+
); // [!code ++]
|
|
560
|
+
await this.outbox.add(events); // [!code ++]
|
|
561
|
+
return ok(); // [!code ++]
|
|
562
|
+
}); // [!code ++]
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
This is the complete handler.
|
|
568
|
+
|
|
569
|
+
### 7. Call it from a driving adapter
|
|
570
|
+
|
|
571
|
+
So that HTTP stays out of the application, a controller builds the command, calls `handle` and turns the `Result` into a response. Domain errors become HTTP errors there, and nowhere else.
|
|
572
|
+
|
|
573
|
+
```ts [src/ordering/driving/http/controllers/orders.controller.ts]
|
|
574
|
+
const placed = await this.placeOrder.handle({ orderId });
|
|
575
|
+
if (!placed.ok) {
|
|
576
|
+
return {
|
|
577
|
+
status: 422,
|
|
578
|
+
body: {
|
|
579
|
+
error: placed.error.type,
|
|
580
|
+
details: placed.error.payload,
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
return { status: 204 };
|
|
585
|
+
```
|
|
586
|
+
|
|
587
|
+
### 8. Check it
|
|
588
|
+
|
|
589
|
+
Run the checks. Three rules keep the handler the way it is now:
|
|
590
|
+
|
|
591
|
+
```sh
|
|
592
|
+
npx alveolus arch check
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
<div class="al-cards">
|
|
596
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-foreign-command-dependency"><code>no-foreign-command-dependency</code></a></span>It receives command repositories, ports, event translators, domain services and value objects: never a query repository or another handler.</div>
|
|
597
|
+
<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>application/commands/*.command.ts</code>.</div>
|
|
598
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-outward-import"><code>no-outward-import</code></a></span>It imports the domain and the application, never an adapter.</div>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
A query repository added to its constructor is reported:
|
|
602
|
+
|
|
603
|
+
```
|
|
604
|
+
src/ordering/application/commands/place-order.command.ts
|
|
605
|
+
46 error tactical/no-foreign-command-dependency: The CommandHandler
|
|
606
|
+
PlaceOrderHandler receives OrderSummaries, a QueryRepository: a
|
|
607
|
+
command handler receives command repositories, ports, event
|
|
608
|
+
translators, domain services and value objects.
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
## See also
|
|
612
|
+
|
|
613
|
+
- [Aggregates](../domain/aggregates.md), which hold the rules the handler calls
|
|
614
|
+
- [Repositories](../domain/repositories.md), to load and save aggregates
|
|
615
|
+
- [Unit of Work](./unit-of-work.md) and [Outbox](./outbox.md), to change and record atomically
|
|
616
|
+
- [Query handlers](./query-handlers.md), for requests that only read
|
|
617
|
+
- Rules: [`tactical/no-foreign-command-dependency`](../../rules/tactical/no-foreign-command-dependency.md), [`layers/no-outward-import`](../../rules/layers/no-outward-import.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
|