@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,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Open host services in Domain-Driven Design with TypeScript: the documented entry point other bounded contexts call, answering in the published language."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Open host services
|
|
6
|
+
|
|
7
|
+
An open host service is the documented entry point of a bounded context: the one class other
|
|
8
|
+
contexts may call, answering in the [published language](./published-language.md).
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Driving</dd>
|
|
12
|
+
<dt>File</dt><dd><code>src/catalog/driving/in-process/catalog-api.ts</code></dd>
|
|
13
|
+
<dt>Implements</dt><dd><a href="#api"><code>OpenHostService</code></a></dd>
|
|
14
|
+
<dt>Called by</dt><dd><a href="/core/strategic/anti-corruption-layers">Anti-corruption layers</a> of other contexts</dd>
|
|
15
|
+
<dt>Checked by</dt><dd><a href="/rules/strategic/no-cross-context-import"><code>strategic/no-cross-context-import</code></a>, <a href="/rules/strategic/no-leaky-host-service"><code>strategic/no-leaky-host-service</code></a>, <a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Ordering needs the price of a product. Without a declared entry point, it imports whatever it finds
|
|
21
|
+
in the catalog: a repository here, a query handler there, the `Product` aggregate itself. The
|
|
22
|
+
catalog now has entry points nobody chose, and cannot change any of them without breaking ordering.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The catalog opens one door: `CatalogApi`. It is the only class other contexts may import, it says
|
|
26
|
+
what the catalog offers, and it answers in JSON. Everything behind it stays free to change.
|
|
27
|
+
:::
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
An open host service is a class of `driving/`, like a controller: it receives a request, calls the
|
|
32
|
+
application of its own context and answers. It marks itself with `implements OpenHostService`.
|
|
33
|
+
|
|
34
|
+
<div class="al-cards">
|
|
35
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Receive plain values</span>The caller passes strings and numbers, never objects of the catalog domain.</div>
|
|
36
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Call the application</span>It calls a <a href="/core/application/query-handlers">query</a> or <a href="/core/application/command-handlers">command handler</a> of its own context. The rules stay there.</div>
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Answer in the published language</span>It maps the result to a representation: JSON, never an aggregate.</div>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
## Where it fits
|
|
41
|
+
|
|
42
|
+
The open host service is the catalog side of the meeting point. On the ordering side, an
|
|
43
|
+
[anti-corruption layer](./anti-corruption-layers.md) calls it and translates its answer.
|
|
44
|
+
|
|
45
|
+
<div class="al-diagram">
|
|
46
|
+
<svg viewBox="0 0 720 260" role="img" aria-label="In ordering, AddLineHandler asks the PriceList port for a price. CatalogPriceList, the anti-corruption layer, extends the port and reads the JSON answered by CatalogApi, the open host service of catalog, which calls GetProductHandler.">
|
|
47
|
+
<defs>
|
|
48
|
+
<marker id="ohs-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
49
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
50
|
+
</marker>
|
|
51
|
+
</defs>
|
|
52
|
+
<rect class="box" x="8" y="8" width="272" height="244" rx="14" />
|
|
53
|
+
<text class="note" x="24" y="30">src/ordering/</text>
|
|
54
|
+
<rect class="box" x="24" y="44" width="240" height="48" rx="8" />
|
|
55
|
+
<text class="label" x="144" y="64" text-anchor="middle">AddLineHandler</text>
|
|
56
|
+
<text class="note" x="144" y="82" text-anchor="middle">asks for a price</text>
|
|
57
|
+
<rect class="box" x="24" y="116" width="240" height="48" rx="8" />
|
|
58
|
+
<text class="label" x="144" y="136" text-anchor="middle">PriceList</text>
|
|
59
|
+
<text class="note" x="144" y="154" text-anchor="middle">port · in your words</text>
|
|
60
|
+
<rect class="box" x="24" y="188" width="240" height="48" rx="8" />
|
|
61
|
+
<text class="label" x="144" y="208" text-anchor="middle">CatalogPriceList</text>
|
|
62
|
+
<text class="note" x="144" y="226" text-anchor="middle">anti-corruption layer</text>
|
|
63
|
+
<path class="link" d="M 144 92 L 144 114" marker-end="url(#ohs-flow-arrow)" />
|
|
64
|
+
<path class="link" d="M 144 188 L 144 166" marker-end="url(#ohs-flow-arrow)" />
|
|
65
|
+
<text class="note" x="154" y="181">extends</text>
|
|
66
|
+
<rect class="box" x="300" y="188" width="156" height="48" rx="8" />
|
|
67
|
+
<text class="label" x="378" y="208" text-anchor="middle">JSON</text>
|
|
68
|
+
<text class="note" x="378" y="226" text-anchor="middle">the contract</text>
|
|
69
|
+
<path class="link" d="M 300 212 L 266 212" marker-end="url(#ohs-flow-arrow)" />
|
|
70
|
+
<path class="link" d="M 492 212 L 458 212" marker-end="url(#ohs-flow-arrow)" />
|
|
71
|
+
<rect class="box" x="476" y="8" width="236" height="244" rx="14" />
|
|
72
|
+
<text class="note" x="492" y="30">src/catalog/</text>
|
|
73
|
+
<rect class="box" x="492" y="44" width="204" height="48" rx="8" />
|
|
74
|
+
<text class="label" x="594" y="64" text-anchor="middle">GetProductHandler</text>
|
|
75
|
+
<text class="note" x="594" y="82" text-anchor="middle">reads the catalog</text>
|
|
76
|
+
<rect class="boundary" x="492" y="188" width="204" height="48" rx="8" />
|
|
77
|
+
<text class="label" x="594" y="208" text-anchor="middle">CatalogApi</text>
|
|
78
|
+
<text class="note" x="594" y="226" text-anchor="middle">open host service</text>
|
|
79
|
+
<path class="link" d="M 594 188 L 594 94" marker-end="url(#ohs-flow-arrow)" />
|
|
80
|
+
<text class="note" x="604" y="145">calls</text>
|
|
81
|
+
</svg>
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
::: tip
|
|
85
|
+
The open host service does not know who calls it. It describes what the catalog offers, in the
|
|
86
|
+
catalog's words; each caller translates on its own side.
|
|
87
|
+
:::
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import type { OpenHostService } from "@alveolus/core";
|
|
93
|
+
// or: import type { OpenHostService } from
|
|
94
|
+
// "@alveolus/core/open-host-services";
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `OpenHostService` <Badge type="info" text="abstract · no members" /> <Badge type="tip" text="you implement it" />
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
abstract class OpenHostService {}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Marks the class as the entry point of its context. It has no members: the class declares its role
|
|
104
|
+
with `implements`, and the rules recognise it.
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
export class CatalogApi implements OpenHostService { … }
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Your methods <Badge type="tip" text="called by anti-corruption layers" />
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
async productById(
|
|
114
|
+
productId: string,
|
|
115
|
+
): Promise<ProductRepresentation | undefined>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Named after what the context offers, such as `productById`. They take plain values and answer in
|
|
119
|
+
the published language, never with the classes of the model. Anti-corruption layers of other
|
|
120
|
+
contexts call them.
|
|
121
|
+
|
|
122
|
+
### The class itself <Badge type="tip" text="passed by the composition root" />
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
new CatalogPriceList(catalog.api)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The composition root of a downstream context receives the open host service and passes it to the
|
|
129
|
+
anti-corruption layers it builds.
|
|
130
|
+
|
|
131
|
+
::: warning Caveats
|
|
132
|
+
- It is the only class another bounded context may import, and only from an anti-corruption layer
|
|
133
|
+
or from its composition root: see [`strategic/no-cross-context-import`](../../rules/strategic/no-cross-context-import.md).
|
|
134
|
+
- It lives in `driving/`, under the name of its technology: see
|
|
135
|
+
[`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md).
|
|
136
|
+
- Use `implements`, not `extends`: the class stays free to extend something else, such as a base
|
|
137
|
+
controller.
|
|
138
|
+
:::
|
|
139
|
+
|
|
140
|
+
## Usage
|
|
141
|
+
|
|
142
|
+
Build `CatalogApi`, the open host service through which other contexts read the catalog, one idea at
|
|
143
|
+
a time. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
|
|
144
|
+
|
|
145
|
+
<div class="al-cards">
|
|
146
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-decide-what-you-publish">Decide what you publish</a></span>Agree on the contract first.</div>
|
|
147
|
+
<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>Mark the one way in.</div>
|
|
148
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-delegate-to-a-use-case">Delegate to a use case</a></span>Reuse the application, decide nothing.</div>
|
|
149
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-answer-in-the-published-language">Answer in the published language</a></span>Never hand out the model.</div>
|
|
150
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-wire-it">Wire it</a></span>Expose it, and nothing else.</div>
|
|
151
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
### 1. Decide what you publish
|
|
155
|
+
|
|
156
|
+
What other contexts receive is the catalog's [published language](./published-language.md):
|
|
157
|
+
`ProductRepresentation`, plain JSON declared in `published-language/product.representation.ts`.
|
|
158
|
+
|
|
159
|
+
### 2. Declare the service
|
|
160
|
+
|
|
161
|
+
So that other contexts know which class they may call, the catalog declares one class under
|
|
162
|
+
`driving/` that implements `OpenHostService`. Nothing else of the catalog may be imported from
|
|
163
|
+
outside.
|
|
164
|
+
|
|
165
|
+
```ts [src/catalog/driving/in-process/catalog-api.ts]
|
|
166
|
+
import type { OpenHostService } from "@alveolus/core";
|
|
167
|
+
|
|
168
|
+
export class CatalogApi implements OpenHostService {}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### 3. Delegate to a use case
|
|
172
|
+
|
|
173
|
+
The open host service holds no rule: it calls a [query handler](../application/query-handlers.md)
|
|
174
|
+
that the catalog already has. It takes plain values, so callers need none of the catalog's
|
|
175
|
+
classes, and answers `undefined` when the product does not exist.
|
|
176
|
+
|
|
177
|
+
```ts [src/catalog/driving/in-process/catalog-api.ts]
|
|
178
|
+
import type { OpenHostService } from "@alveolus/core";
|
|
179
|
+
|
|
180
|
+
export class CatalogApi implements OpenHostService {} // [!code --]
|
|
181
|
+
import { GetProductHandler } from // [!code ++]
|
|
182
|
+
"../../application/queries/get-product.query"; // [!code ++]
|
|
183
|
+
import type { ProductRepresentation } from // [!code ++]
|
|
184
|
+
"../../published-language/product.representation"; // [!code ++]
|
|
185
|
+
|
|
186
|
+
export class CatalogApi implements OpenHostService { // [!code ++]
|
|
187
|
+
constructor(private readonly getProduct: GetProductHandler) {} // [!code ++]
|
|
188
|
+
|
|
189
|
+
async productById( // [!code ++]
|
|
190
|
+
productId: string, // [!code ++]
|
|
191
|
+
): Promise<ProductRepresentation | undefined> { // [!code ++]
|
|
192
|
+
const product = await this.getProduct.handle({ productId }); // [!code ++]
|
|
193
|
+
if (!product.ok) { // [!code ++]
|
|
194
|
+
return undefined; // [!code ++]
|
|
195
|
+
} // [!code ++]
|
|
196
|
+
} // [!code ++]
|
|
197
|
+
} // [!code ++]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### 4. Answer in the published language
|
|
201
|
+
|
|
202
|
+
So that the catalog can change its model without breaking anyone, the service answers with plain
|
|
203
|
+
JSON, never with a view, an aggregate or a value object.
|
|
204
|
+
|
|
205
|
+
```ts [src/catalog/driving/in-process/catalog-api.ts]
|
|
206
|
+
import type { OpenHostService } from "@alveolus/core";
|
|
207
|
+
|
|
208
|
+
import { GetProductHandler } from
|
|
209
|
+
"../../application/queries/get-product.query";
|
|
210
|
+
import type { ProductRepresentation } from
|
|
211
|
+
"../../published-language/product.representation";
|
|
212
|
+
|
|
213
|
+
export class CatalogApi implements OpenHostService {
|
|
214
|
+
constructor(private readonly getProduct: GetProductHandler) {}
|
|
215
|
+
|
|
216
|
+
async productById(
|
|
217
|
+
productId: string,
|
|
218
|
+
): Promise<ProductRepresentation | undefined> {
|
|
219
|
+
const product = await this.getProduct.handle({ productId });
|
|
220
|
+
if (!product.ok) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
const { id, name, price } = product.value; // [!code ++]
|
|
224
|
+
return { // [!code ++]
|
|
225
|
+
id: id.value, // [!code ++]
|
|
226
|
+
name, // [!code ++]
|
|
227
|
+
price: { amount: price.amount, currency: price.currency }, // [!code ++]
|
|
228
|
+
}; // [!code ++]
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 5. Wire it
|
|
234
|
+
|
|
235
|
+
The composition root of the catalog builds the service and exposes it, and nothing else. The one
|
|
236
|
+
of ordering receives it to build its [anti-corruption layer](./anti-corruption-layers.md).
|
|
237
|
+
|
|
238
|
+
```ts [src/catalog/catalog.module.ts]
|
|
239
|
+
export class CatalogModule {
|
|
240
|
+
readonly api: CatalogApi;
|
|
241
|
+
|
|
242
|
+
constructor(db: Pool) {
|
|
243
|
+
const getProduct = new GetProductHandler(new PgProducts(db));
|
|
244
|
+
this.api = new CatalogApi(getProduct);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```ts [src/ordering/ordering.module.ts]
|
|
250
|
+
export class OrderingModule {
|
|
251
|
+
constructor(db: Pool, catalog: CatalogModule) {
|
|
252
|
+
const prices = new CatalogPriceList(catalog.api);
|
|
253
|
+
…
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### 6. Check it
|
|
259
|
+
|
|
260
|
+
Run the checks. Three rules keep the service the only door of the catalog:
|
|
261
|
+
|
|
262
|
+
```sh
|
|
263
|
+
npx alveolus arch check
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
<div class="al-cards">
|
|
267
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/strategic/no-cross-context-import"><code>strategic/no-cross-context-import</code></a></span>Another context imports this class, and nothing else of the catalog.</div>
|
|
268
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/strategic/no-leaky-host-service"><code>strategic/no-leaky-host-service</code></a></span>It answers in the published language, never with a class of the catalog.</div>
|
|
269
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a></span>It stays under <code>driving/</code>.</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
An import that reaches past it, into the catalog's domain, is reported:
|
|
273
|
+
|
|
274
|
+
```
|
|
275
|
+
src/ordering/driven/catalog/adapters/catalog-price-list.adapter.ts
|
|
276
|
+
4 error strategic/no-cross-context-import: Imports
|
|
277
|
+
src/catalog/domain/aggregates/product.aggregate.ts (catalog domain):
|
|
278
|
+
only an OpenHostService of another bounded context may be imported.
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## See also
|
|
282
|
+
|
|
283
|
+
- [Published Language](./published-language.md), what it answers in
|
|
284
|
+
- [Anti-corruption layers](./anti-corruption-layers.md), how another context reads it
|
|
285
|
+
- [Query handlers](../application/query-handlers.md), what it usually calls
|
|
286
|
+
- [Project layout: bounded contexts](../../guide/project-layout.md#bounded-contexts)
|
|
287
|
+
- Rules: [`strategic/no-cross-context-import`](../../rules/strategic/no-cross-context-import.md), [`strategic/no-leaky-host-service`](../../rules/strategic/no-leaky-host-service.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Published Language in Domain-Driven Design: the JSON contract that bounded contexts exchange instead of importing each other's code."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Published Language
|
|
6
|
+
|
|
7
|
+
The published language is the JSON that bounded contexts exchange: the contract between them,
|
|
8
|
+
instead of their code.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Layer</dt><dd>Published language</dd>
|
|
12
|
+
<dt>File</dt><dd><code>src/catalog/published-language/product.representation.ts</code></dd>
|
|
13
|
+
<dt>Wraps</dt><dd><a href="#api"><code>PublishedLanguage<Representation></code></a></dd>
|
|
14
|
+
<dt>Used by</dt><dd><a href="/core/strategic/open-host-services">Open host services</a>, <a href="/core/strategic/anti-corruption-layers">anti-corruption layers</a>, <a href="/core/application/integration-events">integration events</a></dd>
|
|
15
|
+
<dt>Checked by</dt><dd><a href="/rules/strategic/no-cross-context-import"><code>strategic/no-cross-context-import</code></a>, <a href="/rules/layers/no-outward-import"><code>layers/no-outward-import</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Ordering needs the price of a product. The quickest way is to import the `Product` aggregate of
|
|
21
|
+
the catalog, or its repository. From then on, every rename in the catalog breaks ordering, and the
|
|
22
|
+
catalog can no longer change its model without asking every context that reads it.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The catalog publishes a format: plain JSON, named and typed, one file per representation. Other
|
|
26
|
+
contexts depend on that format, never on the code behind it, and the catalog changes its model
|
|
27
|
+
freely as long as the format holds.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
A representation is a type wrapped in `PublishedLanguage<…>`. The wrapper changes nothing at
|
|
33
|
+
runtime: it checks, at compile time, that the type is JSON. Each side of the exchange declares the
|
|
34
|
+
representation in its own `published-language/` folder.
|
|
35
|
+
|
|
36
|
+
<div class="al-cards">
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>The upstream publishes</span>The catalog declares <code>ProductRepresentation</code>: everything it agrees to expose.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Its open host service answers</span><a href="/core/strategic/open-host-services">CatalogApi</a> maps the domain to the representation.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>The downstream reads</span>Ordering declares only the fields it reads; its <a href="/core/strategic/anti-corruption-layers">anti-corruption layer</a> turns them into its own objects.</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
## Where it fits
|
|
43
|
+
|
|
44
|
+
The published language is what crosses the line between two contexts: the answer of an open host
|
|
45
|
+
service, the payload of an [integration event](../application/integration-events.md). Nothing else
|
|
46
|
+
crosses it.
|
|
47
|
+
|
|
48
|
+
<div class="al-diagram">
|
|
49
|
+
<svg viewBox="0 0 720 260" role="img" aria-label="In ordering, AddLineHandler asks the PriceList port for a price. CatalogPriceList, the anti-corruption layer, extends the port and reads the JSON answered by CatalogApi, the open host service of catalog, which calls GetProductHandler.">
|
|
50
|
+
<defs>
|
|
51
|
+
<marker id="pl-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
52
|
+
<path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
|
|
53
|
+
</marker>
|
|
54
|
+
</defs>
|
|
55
|
+
<rect class="box" x="8" y="8" width="272" height="244" rx="14" />
|
|
56
|
+
<text class="note" x="24" y="30">src/ordering/</text>
|
|
57
|
+
<rect class="box" x="24" y="44" width="240" height="48" rx="8" />
|
|
58
|
+
<text class="label" x="144" y="64" text-anchor="middle">AddLineHandler</text>
|
|
59
|
+
<text class="note" x="144" y="82" text-anchor="middle">asks for a price</text>
|
|
60
|
+
<rect class="box" x="24" y="116" width="240" height="48" rx="8" />
|
|
61
|
+
<text class="label" x="144" y="136" text-anchor="middle">PriceList</text>
|
|
62
|
+
<text class="note" x="144" y="154" text-anchor="middle">port · in your words</text>
|
|
63
|
+
<rect class="box" x="24" y="188" width="240" height="48" rx="8" />
|
|
64
|
+
<text class="label" x="144" y="208" text-anchor="middle">CatalogPriceList</text>
|
|
65
|
+
<text class="note" x="144" y="226" text-anchor="middle">anti-corruption layer</text>
|
|
66
|
+
<path class="link" d="M 144 92 L 144 114" marker-end="url(#pl-flow-arrow)" />
|
|
67
|
+
<path class="link" d="M 144 188 L 144 166" marker-end="url(#pl-flow-arrow)" />
|
|
68
|
+
<text class="note" x="154" y="181">extends</text>
|
|
69
|
+
<rect class="boundary" x="300" y="188" width="156" height="48" rx="8" />
|
|
70
|
+
<text class="label" x="378" y="208" text-anchor="middle">JSON</text>
|
|
71
|
+
<text class="note" x="378" y="226" text-anchor="middle">the contract</text>
|
|
72
|
+
<path class="link" d="M 300 212 L 266 212" marker-end="url(#pl-flow-arrow)" />
|
|
73
|
+
<path class="link" d="M 492 212 L 458 212" marker-end="url(#pl-flow-arrow)" />
|
|
74
|
+
<rect class="box" x="476" y="8" width="236" height="244" rx="14" />
|
|
75
|
+
<text class="note" x="492" y="30">src/catalog/</text>
|
|
76
|
+
<rect class="box" x="492" y="44" width="204" height="48" rx="8" />
|
|
77
|
+
<text class="label" x="594" y="64" text-anchor="middle">GetProductHandler</text>
|
|
78
|
+
<text class="note" x="594" y="82" text-anchor="middle">reads the catalog</text>
|
|
79
|
+
<rect class="box" x="492" y="188" width="204" height="48" rx="8" />
|
|
80
|
+
<text class="label" x="594" y="208" text-anchor="middle">CatalogApi</text>
|
|
81
|
+
<text class="note" x="594" y="226" text-anchor="middle">open host service</text>
|
|
82
|
+
<path class="link" d="M 594 188 L 594 94" marker-end="url(#pl-flow-arrow)" />
|
|
83
|
+
<text class="note" x="604" y="145">calls</text>
|
|
84
|
+
</svg>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
::: tip
|
|
88
|
+
Inside a context, use the objects of the domain: aggregates, value objects, identifiers. The
|
|
89
|
+
published language exists only where a context meets another one.
|
|
90
|
+
:::
|
|
91
|
+
|
|
92
|
+
## API
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import type { PublishedLanguage } from "@alveolus/core";
|
|
96
|
+
// or: import type { PublishedLanguage } from
|
|
97
|
+
// "@alveolus/core/published-language";
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Type parameters
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
type PublishedLanguage<Representation extends JsonValue> =
|
|
104
|
+
Representation;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
| Parameter | What it is | Constraint |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| `Representation` | The JSON shape of what is exchanged. | extends `JsonValue` |
|
|
110
|
+
|
|
111
|
+
### `PublishedLanguage<Representation>` <Badge type="info" text="type" /> <Badge type="tip" text="you write it" />
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
type ProductRepresentation = PublishedLanguage<{
|
|
115
|
+
readonly id: string;
|
|
116
|
+
readonly name: string;
|
|
117
|
+
}>;
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Wraps one representation and checks at compile time that it is JSON. It returns the same type.
|
|
121
|
+
|
|
122
|
+
### `JsonValue` <Badge type="info" text="type" />
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
type JsonValue =
|
|
126
|
+
| string
|
|
127
|
+
| number
|
|
128
|
+
| boolean
|
|
129
|
+
| null
|
|
130
|
+
| readonly JsonValue[]
|
|
131
|
+
| { readonly [key: string]: JsonValue };
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Any JSON value: string, number, boolean, `null`, and arrays and objects of them. It is the
|
|
135
|
+
constraint of `Representation`.
|
|
136
|
+
|
|
137
|
+
::: warning Caveats
|
|
138
|
+
- `PublishedLanguage` changes nothing at runtime: the type is the JSON type it wraps.
|
|
139
|
+
- Declare representations with `type`, not `interface`: an interface does not satisfy the index
|
|
140
|
+
signature of `JsonValue`.
|
|
141
|
+
- Files in `published-language/` import only their own published language, the published-language
|
|
142
|
+
types of core (`PublishedLanguage`, `JsonValue`, `IntegrationEvent`, `AnyIntegrationEvent`) and
|
|
143
|
+
packages such as a schema library: see [`layers/no-outward-import`](../../rules/layers/no-outward-import.md).
|
|
144
|
+
:::
|
|
145
|
+
|
|
146
|
+
## Usage
|
|
147
|
+
|
|
148
|
+
Build the contract the catalog publishes and the copy ordering reads, one idea at a time. Each step
|
|
149
|
+
shows the whole file: added lines are highlighted.
|
|
150
|
+
|
|
151
|
+
<div class="al-cards">
|
|
152
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-find-what-crosses-the-boundary">Find what crosses the boundary</a></span>Start from what others need.</div>
|
|
153
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-what-you-publish">Declare what you publish</a></span>A JSON type, in its own folder.</div>
|
|
154
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-write-values-as-plain-json">Write values as plain JSON</a></span>No value object crosses.</div>
|
|
155
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-redeclare-what-you-read">Redeclare what you read</a></span>Downstream keeps its own copy.</div>
|
|
156
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-use-it-on-both-sides">Use it on both sides</a></span>Answer with one, read with the other.</div>
|
|
157
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-check-it">Check it</a></span>Let the rules keep it that way.</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
### 1. Find what crosses the boundary
|
|
161
|
+
|
|
162
|
+
Ordering needs the price of a product, and the catalog owns products: the catalog publishes them
|
|
163
|
+
through its [open host service](./open-host-services.md).
|
|
164
|
+
|
|
165
|
+
### 2. Declare what you publish
|
|
166
|
+
|
|
167
|
+
So that other contexts get a contract and not the model, the catalog declares the shape it sends
|
|
168
|
+
in `published-language/`, with `PublishedLanguage`. Identifiers are written as raw strings.
|
|
169
|
+
|
|
170
|
+
```ts [src/catalog/published-language/product.representation.ts]
|
|
171
|
+
import type { PublishedLanguage } from "@alveolus/core";
|
|
172
|
+
|
|
173
|
+
export type ProductRepresentation = PublishedLanguage<{
|
|
174
|
+
readonly id: string;
|
|
175
|
+
readonly name: string;
|
|
176
|
+
}>;
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### 3. Write values as plain JSON
|
|
180
|
+
|
|
181
|
+
A `Money` is a class of the catalog's model: it is sent as plain fields. `PublishedLanguage` only
|
|
182
|
+
accepts JSON values, so a class, a `Date` or `undefined` does not compile.
|
|
183
|
+
|
|
184
|
+
```ts [src/catalog/published-language/product.representation.ts]
|
|
185
|
+
import type { PublishedLanguage } from "@alveolus/core";
|
|
186
|
+
|
|
187
|
+
export type ProductRepresentation = PublishedLanguage<{
|
|
188
|
+
readonly id: string;
|
|
189
|
+
readonly name: string;
|
|
190
|
+
readonly price: { // [!code ++]
|
|
191
|
+
readonly amount: number; // [!code ++]
|
|
192
|
+
readonly currency: string; // [!code ++]
|
|
193
|
+
}; // [!code ++]
|
|
194
|
+
}>;
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### 4. Redeclare what you read
|
|
198
|
+
|
|
199
|
+
Ordering does not import the catalog's type: it redeclares, in its own `published-language/`, only
|
|
200
|
+
the fields it reads. The catalog may add fields without touching ordering.
|
|
201
|
+
|
|
202
|
+
```ts [src/ordering/published-language/catalog-product.representation.ts]
|
|
203
|
+
import type { PublishedLanguage } from "@alveolus/core";
|
|
204
|
+
|
|
205
|
+
export type CatalogProductRepresentation = PublishedLanguage<{
|
|
206
|
+
readonly id: string;
|
|
207
|
+
readonly price: {
|
|
208
|
+
readonly amount: number;
|
|
209
|
+
readonly currency: string;
|
|
210
|
+
};
|
|
211
|
+
}>;
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
### 5. Use it on both sides
|
|
215
|
+
|
|
216
|
+
The open host service of the catalog answers with the first type; the
|
|
217
|
+
[anti-corruption layer](./anti-corruption-layers.md) of ordering reads the answer as the second,
|
|
218
|
+
and TypeScript checks that both shapes still match.
|
|
219
|
+
|
|
220
|
+
```ts [src/catalog/driving/in-process/catalog-api.ts]
|
|
221
|
+
async productById(
|
|
222
|
+
productId: string,
|
|
223
|
+
): Promise<ProductRepresentation | undefined> {
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
```ts [src/ordering/driven/catalog/adapters/catalog-price-list.adapter.ts]
|
|
227
|
+
const product: CatalogProductRepresentation | undefined =
|
|
228
|
+
await this.catalog.productById(productId.value);
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### 6. Check it
|
|
232
|
+
|
|
233
|
+
Run the checks. Two rules keep the published language a contract:
|
|
234
|
+
|
|
235
|
+
```sh
|
|
236
|
+
npx alveolus arch check
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
<div class="al-cards">
|
|
240
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/strategic/no-cross-context-import"><code>strategic/no-cross-context-import</code></a></span>No context imports the published language of another: it redeclares it.</div>
|
|
241
|
+
<div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-outward-import"><code>layers/no-outward-import</code></a></span>The published language imports only published-language types.</div>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
Importing the catalog's type instead of redeclaring it is reported:
|
|
245
|
+
|
|
246
|
+
```
|
|
247
|
+
src/ordering/driven/catalog/adapters/catalog-price-list.adapter.ts
|
|
248
|
+
8 error strategic/no-cross-context-import: Imports the published language
|
|
249
|
+
of catalog: redeclare the fields you read in your own
|
|
250
|
+
published-language/.
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Troubleshooting
|
|
254
|
+
|
|
255
|
+
**`Type '…' does not satisfy the constraint 'JsonValue'`**: the representation holds something that
|
|
256
|
+
is not JSON (a `Date`, a class instance, a function), or it is declared with `interface`. Convert the
|
|
257
|
+
value to JSON, or declare the shape with `type`.
|
|
258
|
+
|
|
259
|
+
## See also
|
|
260
|
+
|
|
261
|
+
- [Open host services](./open-host-services.md), which answer in the published language
|
|
262
|
+
- [Anti-corruption layers](./anti-corruption-layers.md), which read it
|
|
263
|
+
- [Integration events](../application/integration-events.md), the events in the published language
|
|
264
|
+
- [Project layout: bounded contexts](../../guide/project-layout.md#bounded-contexts)
|
|
265
|
+
- Rules: [`strategic/no-cross-context-import`](../../rules/strategic/no-cross-context-import.md), [`layers/no-outward-import`](../../rules/layers/no-outward-import.md)
|