@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,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: every driven adapter implements a port declared by the domain, as hexagonal architecture requires."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-portless-adapter
|
|
6
|
+
|
|
7
|
+
A driven adapter exists to implement a port: every class in `driven/<technology>/adapters/`
|
|
8
|
+
extends one, and every port is declared by the domain.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>layers/no-portless-adapter</code></dd>
|
|
12
|
+
<dt>Category</dt><dd><a href="/rules/#layers">Layers</a>: what each layer may depend on</dd>
|
|
13
|
+
<dt>Reports</dt><dd>A driven adapter that extends no port, a port declared outside the domain</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every class in <code>driven/**/adapters/</code>, and every abstract class that extends <code>Port</code>, in core bounded contexts and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"layers/no-portless-adapter": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
A `Mailer` class in `driven/smtp/adapters/` sends emails, and the handler calls it directly. The
|
|
21
|
+
application now depends on SMTP: testing a use case sends mail, and moving to an email API means
|
|
22
|
+
rewriting the handler.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The domain says what it needs in its own words, as a [port](../../core/domain/ports.md); the
|
|
26
|
+
adapter extends that port. The handler only knows the port, so the adapter can be swapped in a
|
|
27
|
+
test or when the technology changes.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
<div class="al-cards al-cards-2">
|
|
33
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Every adapter extends a port</span>Every class in a <code>driven/**/adapters/</code> folder extends a <code>Port</code>, directly or through a repository, <code>Outbox</code>, <code>EventPublisher</code>, <code>UnitOfWork</code>, <code>Clock</code> or <code>IdGenerator</code>.</div>
|
|
34
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Every port lives in the domain</span>Every abstract class that extends <code>Port</code> is declared in <code>domain/ports/</code> or <code>domain/repositories/</code>.</div>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
Abstract base classes for your adapters may also live in `driven/**/adapters/`, as long as they
|
|
38
|
+
extend a port.
|
|
39
|
+
|
|
40
|
+
## What it reports
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
src/ordering/driven/smtp/adapters/mailer.adapter.ts
|
|
44
|
+
1 error layers/no-portless-adapter: Mailer is a driven adapter but
|
|
45
|
+
extends no Port: extend the port it implements.
|
|
46
|
+
|
|
47
|
+
src/ordering/application/commands/notifications.ts
|
|
48
|
+
3 error layers/no-portless-adapter: The port Notifications is declared
|
|
49
|
+
outside the domain: move it to domain/ports/ or
|
|
50
|
+
domain/repositories/.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Fix it
|
|
54
|
+
|
|
55
|
+
### Extend the port the adapter implements
|
|
56
|
+
|
|
57
|
+
So that the application depends on what it needs and not on the technology, declare a port in the
|
|
58
|
+
domain and make the adapter extend it. Name the adapter after its technology and its port.
|
|
59
|
+
|
|
60
|
+
<div class="al-compare">
|
|
61
|
+
|
|
62
|
+
```ts [❌ Avoid: src/ordering/driven/smtp/adapters/mailer.adapter.ts]
|
|
63
|
+
export class Mailer {
|
|
64
|
+
async send(to: string, body: string): Promise<void> {}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```ts [✅ Prefer: src/ordering/driven/smtp/adapters/smtp-notifications.adapter.ts]
|
|
69
|
+
import { Notifications } from "../../../domain/ports/notifications.port";
|
|
70
|
+
|
|
71
|
+
export class SmtpNotifications extends Notifications {
|
|
72
|
+
async orderPlaced(email: string): Promise<void> {}
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
### Declare ports in the domain
|
|
79
|
+
|
|
80
|
+
So that the domain knows every dependency it relies on, a port declared in the application or
|
|
81
|
+
next to an adapter moves to `domain/ports/`, or to `domain/repositories/` for a repository.
|
|
82
|
+
|
|
83
|
+
<div class="al-compare">
|
|
84
|
+
|
|
85
|
+
```ts [❌ Avoid: src/ordering/application/commands/notifications.ts]
|
|
86
|
+
export abstract class Notifications extends Port {
|
|
87
|
+
abstract orderPlaced(email: string): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```ts [✅ Prefer: src/ordering/domain/ports/notifications.port.ts]
|
|
92
|
+
export abstract class Notifications extends Port {
|
|
93
|
+
abstract orderPlaced(email: string): Promise<void>;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
## Limits
|
|
100
|
+
|
|
101
|
+
::: warning What the rule cannot see
|
|
102
|
+
- A class in `driven/<technology>/` outside `adapters/`, such as a mapper or an ORM entity: it is
|
|
103
|
+
not an adapter, so it needs no port, and nothing checks what it talks to.
|
|
104
|
+
- A port written as an interface: a port is an abstract class that extends `Port`, and an adapter
|
|
105
|
+
that `implements` an interface is reported as extending no port.
|
|
106
|
+
:::
|
|
107
|
+
|
|
108
|
+
## Turn it off
|
|
109
|
+
|
|
110
|
+
```ts [alveolus.config.ts]
|
|
111
|
+
rules: { "layers/no-portless-adapter": "off" },
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
115
|
+
new adapters extend a port while you wrap the old ones.
|
|
116
|
+
|
|
117
|
+
## See also
|
|
118
|
+
|
|
119
|
+
- [Ports](../../core/domain/ports.md), what an adapter implements
|
|
120
|
+
- [Project layout: layers](../../guide/project-layout.md#layers)
|
|
121
|
+
- [`tactical/no-misplaced-class`](../tactical/no-misplaced-class.md), for the folder and file name
|
|
122
|
+
of an adapter
|
|
123
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: a bounded context is reached only through its open host service, from an anti-corruption layer or its composition root."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-cross-context-import
|
|
6
|
+
|
|
7
|
+
A bounded context is closed: another context reaches it only through its open host service, from
|
|
8
|
+
an anti-corruption layer or its composition root.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>strategic/no-cross-context-import</code></dd>
|
|
12
|
+
<dt>Category</dt><dd><a href="/rules/#strategic">Strategic</a>: what crosses a bounded context</dd>
|
|
13
|
+
<dt>Reports</dt><dd>An import from another bounded context that is not its open host service, used where it may be</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every file of every bounded context, whatever its subdomain, and of the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"strategic/no-cross-context-import": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
An adapter of ordering imports `Product` from the catalog domain, because it had the fields it
|
|
21
|
+
needed. The two contexts now share one model without anyone deciding it: renaming a field in the
|
|
22
|
+
catalog breaks ordering, and nothing showed the dependency until it broke.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
One door on each side. The catalog exposes an
|
|
26
|
+
[open host service](../../core/strategic/open-host-services.md); ordering calls it from an
|
|
27
|
+
[anti-corruption layer](../../core/strategic/anti-corruption-layers.md) that translates the answer
|
|
28
|
+
into its own model. Everything in between is free to change.
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
## What it checks
|
|
32
|
+
|
|
33
|
+
When a file of one bounded context imports a file of another one:
|
|
34
|
+
|
|
35
|
+
<div class="al-cards al-cards-2">
|
|
36
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Only open host services</span>Every imported name is a class that implements <code>OpenHostService</code>.</div>
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Only from an anti-corruption layer</span>In a core context, the importing file declares a class that implements <code>AntiCorruptionLayer</code>, or is the composition root of its context. A <a href="../../guide/project-layout.md#core-supporting-generic">supporting or generic context</a> calls the service from anywhere: it has no model to protect.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Composition roots meet freely</span>A composition root may import another context's composition root, to reach its open host services. It re-exports nothing, so that no other context reaches its model through it.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span>No foreign published language</span>The published language of another context is never imported, not even its types.</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
The shared kernel imports no bounded context at all. Every context may import the shared kernel.
|
|
43
|
+
|
|
44
|
+
Every form of import counts, see [Every import counts](../index.md#every-import-counts).
|
|
45
|
+
|
|
46
|
+
## What it reports
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/ordering/driven/pg/adapters/stock.adapter.ts
|
|
50
|
+
1 error strategic/no-cross-context-import: Imports
|
|
51
|
+
src/catalog/domain/aggregates/product.aggregate.ts (catalog domain):
|
|
52
|
+
only an OpenHostService of another bounded context may be imported.
|
|
53
|
+
2 error strategic/no-cross-context-import: Imports the published language
|
|
54
|
+
of catalog: redeclare the fields you read in your own
|
|
55
|
+
published-language/.
|
|
56
|
+
3 error strategic/no-cross-context-import: Uses the open host service of
|
|
57
|
+
catalog outside an AntiCorruptionLayer: translate it in an
|
|
58
|
+
anti-corruption layer.
|
|
59
|
+
|
|
60
|
+
src/catalog/catalog.module.ts
|
|
61
|
+
2 error strategic/no-cross-context-import: The composition root
|
|
62
|
+
re-exports Product: it exports its own module only, so that no
|
|
63
|
+
other context reaches through it.
|
|
64
|
+
|
|
65
|
+
src/shared-kernel/domain/value-objects/money.value-object.ts
|
|
66
|
+
1 error strategic/no-cross-context-import: The shared kernel imports no
|
|
67
|
+
bounded context, but imports
|
|
68
|
+
src/catalog/domain/value-objects/currency.value-object.ts
|
|
69
|
+
(catalog domain).
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Fix it
|
|
73
|
+
|
|
74
|
+
### Go through an anti-corruption layer
|
|
75
|
+
|
|
76
|
+
So that only one class knows the other context exists, declare in your domain a port that asks in
|
|
77
|
+
your own words, and implement it in an anti-corruption layer that calls the open host service.
|
|
78
|
+
|
|
79
|
+
<div class="al-compare">
|
|
80
|
+
|
|
81
|
+
```ts [❌ Avoid: src/ordering/driven/pg/adapters/stock.adapter.ts]
|
|
82
|
+
import type { Product } from "../../../../catalog/domain/aggregates/product.aggregate";
|
|
83
|
+
import type { ProductRepresentation } from "../../../../catalog/published-language/product.representation";
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
```ts [✅ Prefer: src/ordering/driven/catalog/adapters/catalog-price-list.adapter.ts]
|
|
87
|
+
import type { AntiCorruptionLayer } from "@alveolus/core";
|
|
88
|
+
|
|
89
|
+
import type { CatalogApi } from "../../../../catalog/driving/in-process/catalog-api";
|
|
90
|
+
import { PriceList } from "../../../domain/ports/price-list.port";
|
|
91
|
+
|
|
92
|
+
export class CatalogPriceList
|
|
93
|
+
extends PriceList
|
|
94
|
+
implements AntiCorruptionLayer
|
|
95
|
+
{
|
|
96
|
+
constructor(private readonly catalog: CatalogApi) {
|
|
97
|
+
super();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
### Redeclare the fields you read
|
|
105
|
+
|
|
106
|
+
So that the upstream context can change its published language without breaking yours, the
|
|
107
|
+
downstream context redeclares the fields it reads in its own `published-language/`, instead of
|
|
108
|
+
importing the upstream types. When the open host service is called in the same process,
|
|
109
|
+
TypeScript still checks that both shapes match, at the anti-corruption layer and nowhere else.
|
|
110
|
+
|
|
111
|
+
### Keep the shared kernel independent
|
|
112
|
+
|
|
113
|
+
So that a change in one context never reaches all the others, the shared kernel imports no
|
|
114
|
+
bounded context. Move what it needs into the shared kernel, or keep it in the context that owns it.
|
|
115
|
+
|
|
116
|
+
## Limits
|
|
117
|
+
|
|
118
|
+
::: warning What the rule cannot see
|
|
119
|
+
- The rule checks what an anti-corruption layer imports, not what it does with it: an adapter that
|
|
120
|
+
returns the open host service's answer as is, untranslated, is accepted. In review, the ACL
|
|
121
|
+
should build values of its own context.
|
|
122
|
+
- A file that matches `ignore` in `alveolus.config.ts` is not analysed at all.
|
|
123
|
+
:::
|
|
124
|
+
|
|
125
|
+
## Turn it off
|
|
126
|
+
|
|
127
|
+
```ts [alveolus.config.ts]
|
|
128
|
+
rules: { "strategic/no-cross-context-import": "off" },
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
132
|
+
new code keeps the boundaries while you remove the old shortcuts.
|
|
133
|
+
|
|
134
|
+
## See also
|
|
135
|
+
|
|
136
|
+
- [Project layout: bounded contexts](../../guide/project-layout.md#bounded-contexts)
|
|
137
|
+
- [Open host services](../../core/strategic/open-host-services.md) and
|
|
138
|
+
[Anti-corruption layers](../../core/strategic/anti-corruption-layers.md)
|
|
139
|
+
- [`layers/no-outward-import`](../layers/no-outward-import.md), for dependencies inside a context
|
|
140
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: the shared kernel holds value objects, ports and their adapters, never an aggregate, a repository or a handler."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-fat-shared-kernel
|
|
6
|
+
|
|
7
|
+
The shared kernel stays small: value objects, identifiers, errors, ports and their adapters.
|
|
8
|
+
Everything that changes with a business belongs to one bounded context.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>strategic/no-fat-shared-kernel</code></dd>
|
|
12
|
+
<dt>Category</dt><dd><a href="/rules/#strategic">Strategic</a>: what crosses a bounded context</dd>
|
|
13
|
+
<dt>Reports</dt><dd>An <code>AggregateRoot</code>, an <code>Entity</code>, a <code>DomainEvent</code>, a <code>DomainService</code>, a repository, a handler or an <code>EventTranslator</code> in the shared kernel</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every class of the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"strategic/no-fat-shared-kernel": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`Customer` is needed by every context, so it lands in the shared kernel. From then on every
|
|
21
|
+
context depends on its shape, its rules and its repository; the KYC team cannot change how a
|
|
22
|
+
customer is verified without a change that reaches the whole system. The shared kernel has become
|
|
23
|
+
the one model nobody can touch.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
Each context keeps its own view of a customer, under its own name: a `Payer` in payments, an
|
|
27
|
+
`Applicant` in KYC, each with the fields it needs. What they share is small and stable: the
|
|
28
|
+
`CustomerId`, the `Money` value object, the ports every context uses.
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
## What it checks
|
|
32
|
+
|
|
33
|
+
Every class in the shared kernel, by what it extends:
|
|
34
|
+
|
|
35
|
+
| Class | Allowed |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| A `ValueObject`, an `Identifier`, a `DomainError` | ✅ |
|
|
38
|
+
| A `Port`, abstract or implemented by an adapter: a tracer, an outbox, a clock | ✅ |
|
|
39
|
+
| A representation of the published language | ✅ |
|
|
40
|
+
| An `AggregateRoot`, an `Entity`, a `DomainEvent`, a `DomainService` | ❌ |
|
|
41
|
+
| A `CommandRepository`, a `QueryRepository` | ❌ |
|
|
42
|
+
| A `CommandHandler`, a `QueryHandler`, an `EventTranslator` | ❌ |
|
|
43
|
+
|
|
44
|
+
## What it reports
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/shared-kernel/domain/aggregates/customer.aggregate.ts
|
|
48
|
+
3 error strategic/no-fat-shared-kernel: Customer is an AggregateRoot in
|
|
49
|
+
the shared kernel: it belongs to one bounded context; the shared
|
|
50
|
+
kernel holds value objects, ports and their adapters.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Fix it
|
|
54
|
+
|
|
55
|
+
### Give the aggregate a home
|
|
56
|
+
|
|
57
|
+
So that one team owns it, move the aggregate, its repository and its handlers to the context that
|
|
58
|
+
decides its rules, and expose what the others need through an
|
|
59
|
+
[open host service](../../core/strategic/open-host-services.md). The identifier stays in the
|
|
60
|
+
shared kernel.
|
|
61
|
+
|
|
62
|
+
## Limits
|
|
63
|
+
|
|
64
|
+
::: warning What the rule cannot see
|
|
65
|
+
- How big the shared kernel is: three hundred value objects pass. In review, a value object enters
|
|
66
|
+
the shared kernel when two contexts already have the same one, not before.
|
|
67
|
+
- A folder declared as a bounded context named `shared` or `common` is a context, not the shared
|
|
68
|
+
kernel: the rule does not apply to it, and every other rule treats it as one more context.
|
|
69
|
+
:::
|
|
70
|
+
|
|
71
|
+
## Turn it off
|
|
72
|
+
|
|
73
|
+
```ts [alveolus.config.ts]
|
|
74
|
+
rules: { "strategic/no-fat-shared-kernel": "off" },
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## See also
|
|
78
|
+
|
|
79
|
+
- [Project layout: shared kernel](../../guide/project-layout.md#shared-kernel)
|
|
80
|
+
- [`strategic/no-unmapped-context`](./no-unmapped-context.md), the other way contexts get tied
|
|
81
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: an open host service speaks the published language, and never exposes a class of its bounded context."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-leaky-host-service
|
|
6
|
+
|
|
7
|
+
An open host service speaks the published language: no class of its context appears in what it
|
|
8
|
+
offers.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>strategic/no-leaky-host-service</code></dd>
|
|
12
|
+
<dt>Category</dt><dd><a href="/rules/#strategic">Strategic</a>: what crosses a bounded context</dd>
|
|
13
|
+
<dt>Reports</dt><dd>A class of the project in the parameters, results, properties or getters of an open host service</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every class that implements <code>OpenHostService</code></dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"strategic/no-leaky-host-service": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`CatalogApi.product` returns the `Product` aggregate, because it was already loaded. Ordering only
|
|
21
|
+
imports the open host service, as the rules ask, yet it now holds the catalog's model: a change to
|
|
22
|
+
`Product` breaks ordering, and ordering can call `product.changePrice()` from outside its
|
|
23
|
+
boundary.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
The service answers in the published language, plain JSON types that the catalog commits to keep
|
|
27
|
+
stable. The model behind it can change freely.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Every public method, property and getter of a class that implements `OpenHostService`: its
|
|
33
|
+
parameters and its result, followed into generics, `Promise`, arrays and object types.
|
|
34
|
+
|
|
35
|
+
| Type | Allowed |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| A published-language type, a plain value | ✅ |
|
|
38
|
+
| A class of the shared kernel, such as `Money` | ✅ |
|
|
39
|
+
| A class of an installed package | ✅ |
|
|
40
|
+
| Any other class of the project: an aggregate, an entity, a value object, an identifier, a domain event | ❌ |
|
|
41
|
+
|
|
42
|
+
The constructor and private members are left out: they wire the service, other contexts never see
|
|
43
|
+
them.
|
|
44
|
+
|
|
45
|
+
## What it reports
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/catalog/driving/in-process/catalog-api.ts
|
|
49
|
+
6 error strategic/no-leaky-host-service: CatalogApi.product exposes
|
|
50
|
+
Product, an AggregateRoot of catalog: an open host service speaks
|
|
51
|
+
the published language.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Fix it
|
|
55
|
+
|
|
56
|
+
### Answer in the published language
|
|
57
|
+
|
|
58
|
+
So that the model can change without breaking other contexts, map it to a representation before it
|
|
59
|
+
leaves the service, and take plain values as parameters.
|
|
60
|
+
|
|
61
|
+
<div class="al-compare">
|
|
62
|
+
|
|
63
|
+
```ts [❌ Avoid: src/catalog/driving/in-process/catalog-api.ts]
|
|
64
|
+
export class CatalogApi implements OpenHostService {
|
|
65
|
+
async product(id: ProductId): Promise<Product | undefined> {
|
|
66
|
+
return this.products.findById(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```ts [✅ Prefer: src/catalog/driving/in-process/catalog-api.ts]
|
|
72
|
+
export class CatalogApi implements OpenHostService {
|
|
73
|
+
async productById(productId: string): Promise<ProductRepresentation | undefined> {
|
|
74
|
+
const result = await this.getProduct.handle({ productId });
|
|
75
|
+
return result.ok ? result.value : undefined;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
## Limits
|
|
83
|
+
|
|
84
|
+
::: warning What the rule cannot see
|
|
85
|
+
- What a method returns as `unknown`, `any` or a plain object typed by hand with the fields of the
|
|
86
|
+
aggregate: the rule follows classes. In review, a method of an open host service returns a type of
|
|
87
|
+
`published-language/`.
|
|
88
|
+
- A DTO class of the context is a leak too, on purpose: the published language is made of types,
|
|
89
|
+
not classes.
|
|
90
|
+
:::
|
|
91
|
+
|
|
92
|
+
## Turn it off
|
|
93
|
+
|
|
94
|
+
```ts [alveolus.config.ts]
|
|
95
|
+
rules: { "strategic/no-leaky-host-service": "off" },
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
99
|
+
new methods answer in the published language while you map the old ones.
|
|
100
|
+
|
|
101
|
+
## See also
|
|
102
|
+
|
|
103
|
+
- [Open host services](../../core/strategic/open-host-services.md), what is checked
|
|
104
|
+
- [Published Language](../../core/strategic/published-language.md), what the service answers in
|
|
105
|
+
- [`strategic/no-cross-context-import`](./no-cross-context-import.md), which makes the service the
|
|
106
|
+
only door of a context
|
|
107
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: a bounded context consumes only the contexts its context map declares, and two contexts never depend on each other."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-unmapped-context
|
|
6
|
+
|
|
7
|
+
A bounded context consumes the contexts its context map declares, and nothing else: two contexts
|
|
8
|
+
never depend on each other.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>strategic/no-unmapped-context</code></dd>
|
|
12
|
+
<dt>Category</dt><dd><a href="/rules/#strategic">Strategic</a>: what crosses a bounded context</dd>
|
|
13
|
+
<dt>Reports</dt><dd>An import of another context that <code>contextMap</code> does not allow; without a map, an import that closes a cycle between contexts</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every file of every bounded context</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"strategic/no-unmapped-context": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`Payments` reads balances from `Ledger`, through its open host service, as it should. A year
|
|
21
|
+
later `Ledger` asks `Payments` whether a transfer is pending, through *its* open host service.
|
|
22
|
+
Each import is clean on its own; together they tie the two contexts: neither can be deployed,
|
|
23
|
+
extracted or rewritten without the other. In a system that lives for years, that knot is what
|
|
24
|
+
turns "change this module" into "rewrite the application".
|
|
25
|
+
|
|
26
|
+
::: tip The fix
|
|
27
|
+
Write the context map, as DDD asks: which context is upstream of which. Declare it in
|
|
28
|
+
`alveolus.config.ts`, and the code can no longer stray from it. A dependency that goes against
|
|
29
|
+
the map is reversed: `Ledger` publishes an event, `Payments` reacts.
|
|
30
|
+
:::
|
|
31
|
+
|
|
32
|
+
## What it checks
|
|
33
|
+
|
|
34
|
+
Every import from a file of one bounded context to a file of another, open host service or
|
|
35
|
+
composition root alike:
|
|
36
|
+
|
|
37
|
+
<div class="al-cards al-cards-2">
|
|
38
|
+
<div class="al-card"><span class="al-card-title">With a context map</span>The importing context lists the imported one in <code>contextMap</code>. The map itself is checked when the configuration loads: an unknown context or a cycle is an error.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title">Without a context map</span>The imports observed form no cycle. An import that closes one is reported at both ends.</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
Imports of the shared kernel are not consumptions: every context may import it.
|
|
43
|
+
|
|
44
|
+
## What it reports
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
src/ledger/driven/payments/adapters/payment-status.adapter.ts
|
|
48
|
+
2 error strategic/no-unmapped-context: ledger consumes payments, which the
|
|
49
|
+
context map does not allow: add payments to contextMap.ledger, or
|
|
50
|
+
reverse the dependency.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Without a map:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
src/ledger/driven/payments/adapters/payment-status.adapter.ts
|
|
57
|
+
2 error strategic/no-unmapped-context: ledger consumes payments, which
|
|
58
|
+
consumes ledger back: two contexts that depend on each other can
|
|
59
|
+
no longer change alone; declare a contextMap and reverse one
|
|
60
|
+
dependency.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Fix it
|
|
64
|
+
|
|
65
|
+
### Declare the context map
|
|
66
|
+
|
|
67
|
+
So that the direction of every dependency is a decision, not an accident, list for each context
|
|
68
|
+
the ones it consumes:
|
|
69
|
+
|
|
70
|
+
```ts [alveolus.config.ts]
|
|
71
|
+
export default defineConfig({
|
|
72
|
+
boundedContexts: { customers: "customers", ledger: "ledger", payments: "payments" },
|
|
73
|
+
contextMap: {
|
|
74
|
+
customers: [],
|
|
75
|
+
ledger: ["customers"],
|
|
76
|
+
payments: ["ledger", "customers"],
|
|
77
|
+
},
|
|
78
|
+
root: "src",
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
A context absent from the map consumes nothing.
|
|
83
|
+
|
|
84
|
+
### Reverse a dependency with an event
|
|
85
|
+
|
|
86
|
+
So that `Ledger` stays upstream, it does not ask `Payments` anything: it publishes
|
|
87
|
+
`TransferSettled` in its [published language](../../core/strategic/published-language.md), and
|
|
88
|
+
`Payments` reacts to it.
|
|
89
|
+
|
|
90
|
+
## Limits
|
|
91
|
+
|
|
92
|
+
::: warning What the rule cannot see
|
|
93
|
+
- A dependency that goes through the database, a queue or an HTTP call to another context's API
|
|
94
|
+
written as a string: the map covers imports. In review, every consumption of another context
|
|
95
|
+
is an import of its open host service.
|
|
96
|
+
:::
|
|
97
|
+
|
|
98
|
+
## Turn it off
|
|
99
|
+
|
|
100
|
+
```ts [alveolus.config.ts]
|
|
101
|
+
rules: { "strategic/no-unmapped-context": "off" },
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## See also
|
|
105
|
+
|
|
106
|
+
- [Open host services](../../core/strategic/open-host-services.md) and
|
|
107
|
+
[Anti-corruption layers](../../core/strategic/anti-corruption-layers.md), how a context consumes another
|
|
108
|
+
- [`strategic/no-cross-context-import`](./no-cross-context-import.md), which keeps the open host
|
|
109
|
+
service the only door
|
|
110
|
+
- Vaughn Vernon, *Domain-Driven Design Distilled*, chapter 4, "Strategic Design with Context Mapping"
|
|
111
|
+
- [Rules](../index.md), every rule by category
|