@alveolus/arch 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-P5PwH9OE.mjs → docs-DcFgskuN.mjs} +214 -43
- package/dist/docs-DcFgskuN.mjs.map +1 -0
- package/dist/index.d.mts +53 -12
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/docs/core/application/command-handlers.md +617 -0
- package/docs/core/application/event-publishers.md +234 -0
- package/docs/core/application/event-translators.md +329 -0
- package/docs/core/application/index.md +99 -0
- package/docs/core/application/integration-events.md +277 -0
- package/docs/core/application/outbox.md +416 -0
- package/docs/core/application/query-handlers.md +292 -0
- package/docs/core/application/unit-of-work.md +352 -0
- package/docs/core/domain/aggregates.md +822 -0
- package/docs/core/domain/domain-errors.md +251 -0
- package/docs/core/domain/domain-events.md +292 -0
- package/docs/core/domain/domain-services.md +249 -0
- package/docs/core/domain/entities.md +431 -0
- package/docs/core/domain/index.md +93 -0
- package/docs/core/domain/ports.md +284 -0
- package/docs/core/domain/repositories.md +335 -0
- package/docs/core/domain/value-objects.md +425 -0
- package/docs/core/domain/views.md +265 -0
- package/docs/core/index.md +108 -0
- package/docs/core/strategic/anti-corruption-layers.md +349 -0
- package/docs/core/strategic/index.md +83 -0
- package/docs/core/strategic/open-host-services.md +287 -0
- package/docs/core/strategic/published-language.md +265 -0
- package/docs/core/utilities/result.md +413 -0
- package/docs/guide/agents.md +68 -0
- package/docs/guide/existing-project.md +108 -0
- package/docs/guide/getting-started.md +286 -0
- package/docs/guide/learning-path.md +123 -0
- package/docs/guide/project-layout.md +324 -0
- package/docs/guide/versioning.md +42 -0
- package/docs/integrations/index.md +112 -0
- package/docs/integrations/nestjs.md +169 -0
- package/docs/rules/index.md +185 -0
- package/docs/rules/layers/no-driving-shortcut.md +119 -0
- package/docs/rules/layers/no-impure-domain.md +191 -0
- package/docs/rules/layers/no-outward-import.md +186 -0
- package/docs/rules/layers/no-portless-adapter.md +123 -0
- package/docs/rules/strategic/no-cross-context-import.md +140 -0
- package/docs/rules/strategic/no-fat-shared-kernel.md +81 -0
- package/docs/rules/strategic/no-leaky-host-service.md +107 -0
- package/docs/rules/strategic/no-unmapped-context.md +114 -0
- package/docs/rules/tactical/no-aggregate-reference.md +139 -0
- package/docs/rules/tactical/no-foreign-command-dependency.md +119 -0
- package/docs/rules/tactical/no-foreign-query-dependency.md +201 -0
- package/docs/rules/tactical/no-loose-code.md +171 -0
- package/docs/rules/tactical/no-misplaced-class.md +146 -0
- package/docs/rules/tactical/no-public-field.md +113 -0
- package/docs/rules/tactical/no-stateful-service.md +102 -0
- package/docs/rules/tactical/no-thrown-failure.md +162 -0
- package/docs/rules/tooling/no-loose-disable.md +98 -0
- package/package.json +4 -3
- package/dist/cli-P5PwH9OE.mjs.map +0 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: every dependency points towards the domain, as in hexagonal and clean architecture, and only the composition root sees every layer."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-outward-import
|
|
6
|
+
|
|
7
|
+
Every dependency points towards the domain, only the composition root sees every layer, and every
|
|
8
|
+
file belongs to a layer.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>layers/no-outward-import</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>An import that points away from the domain, a package the application may not use, a file outside the layers or in the wrong folder of its layer</dd>
|
|
14
|
+
<dt>Applies to</dt><dd><code>application/</code>, <code>published-language/</code>, <code>driven/</code>, <code>driving/</code>, composition roots and files at the root of <code>src/</code>; in core bounded contexts and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"layers/no-outward-import": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
The `PlaceOrderHandler` imports `PgOrders` to save the order, and a controller imports the mailer
|
|
21
|
+
adapter to send a confirmation. Replacing the database now means rewriting use cases, and a
|
|
22
|
+
controller has started sending emails.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
Every arrow points inwards. The application knows the domain and its ports, never an adapter; each
|
|
26
|
+
adapter knows the application, never the adapters on the other side. The composition root is the
|
|
27
|
+
one place that knows how everything fits, so each layer can be replaced, tested and read on its
|
|
28
|
+
own.
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
## What it checks
|
|
32
|
+
|
|
33
|
+
### Imports between layers
|
|
34
|
+
|
|
35
|
+
Within the same context, or towards the shared kernel:
|
|
36
|
+
|
|
37
|
+
| From | May import |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `application/` | `domain/`, `application/`, its own `published-language/`. Packages: `@alveolus/core`, `domainDependencies`, `applicationDependencies`. |
|
|
40
|
+
| `published-language/` | Its own `published-language/`. From `@alveolus/core`, only `PublishedLanguage`, `IntegrationEvent` and `JsonValue`; other packages, such as a schema library, are fine. |
|
|
41
|
+
| `driven/` | `domain/`, `application/`, `published-language/`, `driven/`, any package. |
|
|
42
|
+
| `driving/` | `domain/`, `application/`, `published-language/`, `driving/`, any package. |
|
|
43
|
+
| the composition root | Anything in its context and in the shared kernel. |
|
|
44
|
+
| files at the root of `src/` | Composition roots, and each other. |
|
|
45
|
+
|
|
46
|
+
No layer imports a composition root. The domain has its own rule,
|
|
47
|
+
[`layers/no-impure-domain`](./no-impure-domain.md); imports from another context are checked by
|
|
48
|
+
[`strategic/no-cross-context-import`](../strategic/no-cross-context-import.md).
|
|
49
|
+
|
|
50
|
+
Every form of import counts, see [Every import counts](../index.md#every-import-counts).
|
|
51
|
+
|
|
52
|
+
### Files outside the layers
|
|
53
|
+
|
|
54
|
+
<div class="al-cards al-cards-2">
|
|
55
|
+
<div class="al-card"><span class="al-card-title">Inside a context</span>A file directly in a context that is not its composition root belongs to no layer. A context, or a feature of the shared kernel, has a single composition root.</div>
|
|
56
|
+
<div class="al-card"><span class="al-card-title">Outside every context</span>A file under <code>src/</code>, outside every declared context and the shared kernel, and not at the root.</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
The layer is the first folder of a context, or the second one in a feature of the shared kernel:
|
|
60
|
+
`ordering/legacy/domain/` is no layer.
|
|
61
|
+
|
|
62
|
+
### Folders inside a layer
|
|
63
|
+
|
|
64
|
+
Each layer expects the folders of the [project layout](../../guide/project-layout.md#the-tree).
|
|
65
|
+
A file in another folder keeps its layer for every other rule, and is reported here. A folder of
|
|
66
|
+
your own goes in `layout.extraFolders` of `alveolus.config.ts`: `{ domain: ["specifications"] }`.
|
|
67
|
+
|
|
68
|
+
| Layer | Expected | Reported |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `domain/` | One folder of a kind: `aggregates/`, `entities/`, `value-objects/`, `events/`, `errors/`, `services/`, `repositories/`, `ports/`, `views/` | A file directly in `domain/`, a deeper folder, another folder name |
|
|
71
|
+
| `application/` | One folder of a kind: `commands/`, `queries/`, `translators/` | The same |
|
|
72
|
+
| `published-language/` | The files directly | Any folder |
|
|
73
|
+
| `driven/` | `<technology>/<folder>/`, such as `pg/adapters/` | A file without a technology, a deeper folder |
|
|
74
|
+
| `driving/` | `<technology>/`, any folders below, such as `http/controllers/` | A file without a technology |
|
|
75
|
+
|
|
76
|
+
## What it reports
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
src/ordering/application/commands/place-order.command.ts
|
|
80
|
+
1 error layers/no-outward-import: The application imports
|
|
81
|
+
@nestjs/common: add it to applicationDependencies if the
|
|
82
|
+
application really needs it.
|
|
83
|
+
3 error layers/no-outward-import: The application layer imports
|
|
84
|
+
src/ordering/driven/pg/adapters/pg-orders.adapter.ts
|
|
85
|
+
(ordering driven): it may only import domain, application,
|
|
86
|
+
published-language.
|
|
87
|
+
|
|
88
|
+
src/ordering/helpers.ts
|
|
89
|
+
1 error layers/no-outward-import: The file is outside the layers:
|
|
90
|
+
move it to domain/, application/, published-language/,
|
|
91
|
+
driven/ or driving/.
|
|
92
|
+
|
|
93
|
+
src/ordering/domain/legacy/v1/aggregates/order.aggregate.ts
|
|
94
|
+
1 error layers/no-outward-import: The file is nested too deep: domain/
|
|
95
|
+
holds one folder per kind, such as domain/aggregates/.
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The rule also reports:
|
|
99
|
+
|
|
100
|
+
| Case | Message |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| A layer imports a composition root | `Imports … : only the composition root wires the layers.` |
|
|
103
|
+
| A file at the root imports inside a context | `Files at the root import composition roots only, not ….` |
|
|
104
|
+
| Two composition roots in a context | `ordering has 2 composition roots (ordering.module.ts, pricing.module.ts): keep one, and move the rest into the layers.` |
|
|
105
|
+
| The published language imports another name from core | `The published language imports … from @alveolus/core: only published-language types are allowed.` |
|
|
106
|
+
| A name not allowed from a restricted package | `The application imports Controller from @nestjs/common: applicationDependencies only allows Injectable.` |
|
|
107
|
+
| A file outside the declared contexts | `The file is outside the bounded contexts and the shared kernel declared in alveolus.config.ts: move it, or add it to ignore.` |
|
|
108
|
+
| A file directly in `domain/` or `application/` | `The file sits directly in domain/: put it in the folder of its kind, such as domain/aggregates/.` |
|
|
109
|
+
| A folder that is no kind | `domain/helpers/ is no folder of the domain: use aggregates/, entities/, …` |
|
|
110
|
+
| An adapter without a technology | `The file is not under a technology: driven/ holds driven/<technology>/<folder>/, such as driven/pg/adapters/.` |
|
|
111
|
+
|
|
112
|
+
## Fix it
|
|
113
|
+
|
|
114
|
+
### Depend on the port, not on the adapter
|
|
115
|
+
|
|
116
|
+
So that replacing the database touches no use case, the handler receives the repository it
|
|
117
|
+
extends from the domain, and the composition root passes it the adapter.
|
|
118
|
+
|
|
119
|
+
<div class="al-compare">
|
|
120
|
+
|
|
121
|
+
```ts [❌ Avoid: src/ordering/application/commands/place-order.command.ts]
|
|
122
|
+
import { Controller } from "@nestjs/common";
|
|
123
|
+
|
|
124
|
+
import { PgOrders } from "../../driven/pg/adapters/pg-orders.adapter";
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```ts [✅ Prefer: src/ordering/application/commands/place-order.command.ts]
|
|
128
|
+
import { CommandHandler } from "@alveolus/core";
|
|
129
|
+
|
|
130
|
+
import { Orders } from "../../domain/repositories/orders.repository";
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
### Give every file a layer and a folder
|
|
136
|
+
|
|
137
|
+
So that every file has a known place, move a helper into the layer that uses it, as a building
|
|
138
|
+
block, in the folder of its kind. A barrel such as `domain/index.ts` is not needed: import each
|
|
139
|
+
file from its folder. A file that has nothing to do with the architecture, such as a script, can be left out with
|
|
140
|
+
`ignore` in `alveolus.config.ts`.
|
|
141
|
+
|
|
142
|
+
## Allow a package
|
|
143
|
+
|
|
144
|
+
The application imports no framework: the composition root builds its classes. A package it really
|
|
145
|
+
needs is declared, with `true` to allow everything it exports, or with the names you allow:
|
|
146
|
+
|
|
147
|
+
```ts [alveolus.config.ts]
|
|
148
|
+
export default defineConfig({
|
|
149
|
+
applicationDependencies: {
|
|
150
|
+
"@nestjs/common": ["Injectable"],
|
|
151
|
+
zod: true,
|
|
152
|
+
},
|
|
153
|
+
boundedContexts: { ordering: "ordering" },
|
|
154
|
+
contextMap: { ordering: { consumes: [] } },
|
|
155
|
+
root: "src",
|
|
156
|
+
subdomains: { core: ["ordering"] },
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
The packages of `domainDependencies` are allowed in the application too.
|
|
161
|
+
|
|
162
|
+
## Limits
|
|
163
|
+
|
|
164
|
+
::: warning What the rule cannot see
|
|
165
|
+
- Below its technology, an adapter layer may hold any folder: `driving/http/controllers/v1/` is
|
|
166
|
+
fine. What those folders contain is checked by
|
|
167
|
+
[`tactical/no-loose-code`](../tactical/no-loose-code.md): classes only.
|
|
168
|
+
- A file that matches `ignore` in `alveolus.config.ts` is not analysed at all. Review a change to
|
|
169
|
+
`ignore` as you would review a rule turned off.
|
|
170
|
+
:::
|
|
171
|
+
|
|
172
|
+
## Turn it off
|
|
173
|
+
|
|
174
|
+
```ts [alveolus.config.ts]
|
|
175
|
+
rules: { "layers/no-outward-import": "off" },
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
179
|
+
new code keeps the arrows inwards while you fix the old ones.
|
|
180
|
+
|
|
181
|
+
## See also
|
|
182
|
+
|
|
183
|
+
- [Project layout: who may import what](../../guide/project-layout.md#who-may-import-what)
|
|
184
|
+
- [Integrations](../../integrations/index.md), to build the classes in the composition root
|
|
185
|
+
- [`layers/no-impure-domain`](./no-impure-domain.md), the same idea for the domain
|
|
186
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -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
|