@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,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture checks for Domain-Driven Design in TypeScript: alveolus arch check reports every way a project drifts from its layers and bounded contexts."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Rules
|
|
6
|
+
|
|
7
|
+
`alveolus arch check` applies rules that each report one way a project drifts: a shortcut between
|
|
8
|
+
bounded contexts, a framework leaking into the domain, a helper that lands nowhere in particular.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Command</dt><dd><code>npx alveolus arch check</code></dd>
|
|
12
|
+
<dt>Rule names</dt><dd><a href="#how-rules-are-named"><code><category>/no-<what it reports></code></a></dd>
|
|
13
|
+
<dt>Categories</dt><dd><a href="#strategic">Strategic</a>, <a href="#layers">Layers</a>, <a href="#tactical">Tactical</a></dd>
|
|
14
|
+
<dt>Default</dt><dd>Every rule on; test files never checked</dd>
|
|
15
|
+
<dt>Where</dt><dd><a href="#where-a-rule-applies">Every rule on a core context, the boundary rules on a supporting or generic one</a></dd>
|
|
16
|
+
<dt>Config</dt><dd><a href="#turn-a-rule-off"><code>rules</code></a> in <code>alveolus.config.ts</code></dd>
|
|
17
|
+
</dl>
|
|
18
|
+
|
|
19
|
+
## Why
|
|
20
|
+
|
|
21
|
+
A review catches what a reviewer looks at. The import that crosses a boundary, the class in the
|
|
22
|
+
wrong folder or the error thrown instead of returned slip through, one change at a time, and
|
|
23
|
+
coding agents make more changes than anyone reviews.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
Each architecture decision becomes a rule that runs on every change. A violation says where, what
|
|
27
|
+
is wrong and what is allowed instead, so a developer or an agent can fix it without knowing the
|
|
28
|
+
whole architecture.
|
|
29
|
+
:::
|
|
30
|
+
|
|
31
|
+
## How rules are named
|
|
32
|
+
|
|
33
|
+
Every rule is named `<category>/no-<what it reports>`: the category says which part of the
|
|
34
|
+
architecture it guards, the rest says what a violation is.
|
|
35
|
+
|
|
36
|
+
<div class="al-cards">
|
|
37
|
+
<div class="al-card"><span class="al-card-title"><code>strategic/</code></span>Between bounded contexts: what may cross a boundary, and through which door.</div>
|
|
38
|
+
<div class="al-card"><span class="al-card-title"><code>layers/</code></span>Inside a bounded context: which layer may depend on which, and what each one may import.</div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title"><code>tactical/</code></span>Inside the domain and the application: how building blocks are written and where they live.</div>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
## Where a rule applies
|
|
43
|
+
|
|
44
|
+
`subdomains` in `alveolus.config.ts` says which bounded contexts are
|
|
45
|
+
[core, supporting or generic](../guide/project-layout.md#core-supporting-generic). A core context
|
|
46
|
+
and the shared kernel are checked by every rule. A supporting or generic context is checked only
|
|
47
|
+
by the rules about its boundary, the `strategic/` and `tooling/` ones: how it is written inside is
|
|
48
|
+
its own business. The "Applies to" line of each rule page says which it is.
|
|
49
|
+
|
|
50
|
+
## Strategic
|
|
51
|
+
|
|
52
|
+
| Rule | Reports |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| [`strategic/no-cross-context-import`](./strategic/no-cross-context-import.md) | An import from another bounded context that is not its open host service, a composition root that re-exports. |
|
|
55
|
+
| [`strategic/no-fat-shared-kernel`](./strategic/no-fat-shared-kernel.md) | An aggregate, a repository or a handler in the shared kernel. |
|
|
56
|
+
| [`strategic/no-leaky-host-service`](./strategic/no-leaky-host-service.md) | An open host service that exposes a class of its context instead of the published language. |
|
|
57
|
+
| [`strategic/no-unmapped-context`](./strategic/no-unmapped-context.md) | A context consuming one the context map does not allow, or two contexts that depend on each other. |
|
|
58
|
+
|
|
59
|
+
## Layers
|
|
60
|
+
|
|
61
|
+
In core bounded contexts and the shared kernel.
|
|
62
|
+
|
|
63
|
+
| Rule | Reports |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| [`layers/no-driving-shortcut`](./layers/no-driving-shortcut.md) | A driving adapter reaching a repository, a port or an aggregate instead of calling a handler. |
|
|
66
|
+
| [`layers/no-impure-domain`](./layers/no-impure-domain.md) | The domain importing a framework, a database or another layer. |
|
|
67
|
+
| [`layers/no-outward-import`](./layers/no-outward-import.md) | A dependency pointing away from the domain, and a file outside the layers. |
|
|
68
|
+
| [`layers/no-portless-adapter`](./layers/no-portless-adapter.md) | A driven adapter that extends no port, a port declared outside the domain. |
|
|
69
|
+
|
|
70
|
+
## Tactical
|
|
71
|
+
|
|
72
|
+
In core bounded contexts and the shared kernel.
|
|
73
|
+
|
|
74
|
+
| Rule | Reports |
|
|
75
|
+
| --- | --- |
|
|
76
|
+
| [`tactical/no-aggregate-reference`](./tactical/no-aggregate-reference.md) | An aggregate holding another aggregate instead of its identifier, an entity held by two aggregates. |
|
|
77
|
+
| [`tactical/no-foreign-command-dependency`](./tactical/no-foreign-command-dependency.md) | A command handler receiving a query repository, another handler or a plain class. |
|
|
78
|
+
| [`tactical/no-foreign-query-dependency`](./tactical/no-foreign-query-dependency.md) | A query handler receiving what writes or changes state. |
|
|
79
|
+
| [`tactical/no-loose-code`](./tactical/no-loose-code.md) | Code outside a building block in the domain or the application: a plain or static-only class, a class that extends an expression, a function, an enum, a namespace, module state, a computed constant; anything but the module in a composition root. |
|
|
80
|
+
| [`tactical/no-misplaced-class`](./tactical/no-misplaced-class.md) | A class in the wrong folder or file, two classes in one file. |
|
|
81
|
+
| [`tactical/no-public-field`](./tactical/no-public-field.md) | A public field on an aggregate, an entity, a value object or an identifier. |
|
|
82
|
+
| [`tactical/no-stateful-service`](./tactical/no-stateful-service.md) | A domain service holding a port, a repository or another service. |
|
|
83
|
+
| [`tactical/no-thrown-failure`](./tactical/no-thrown-failure.md) | A business failure thrown instead of returned. |
|
|
84
|
+
|
|
85
|
+
## Tooling
|
|
86
|
+
|
|
87
|
+
| Rule | Reports |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| [`tooling/no-loose-disable`](./tooling/no-loose-disable.md) | A disable comment that names no known rule, gives no reason, or disables nothing. |
|
|
90
|
+
|
|
91
|
+
## Read a violation
|
|
92
|
+
|
|
93
|
+
Violations are grouped by file. Each one gives the line, the rule, what is wrong and what is allowed
|
|
94
|
+
instead:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
src/ordering/application/commands/place-order.command.ts
|
|
98
|
+
4 error layers/no-outward-import: The application layer imports
|
|
99
|
+
src/ordering/driven/pg/adapters/mailer.adapter.ts (ordering driven):
|
|
100
|
+
it may only import domain, application, published-language.
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`--format json` gives the same information as JSON, with the symbol involved, for tools and
|
|
104
|
+
agents.
|
|
105
|
+
|
|
106
|
+
## Building blocks are recognised by inheritance
|
|
107
|
+
|
|
108
|
+
The rules know what a class is from what it extends: `class Order extends AggregateRoot` is an
|
|
109
|
+
aggregate, wherever it is and whatever its name. A class that extends one of your own base classes
|
|
110
|
+
counts too, as long as that base class extends a building block of `@alveolus/core`.
|
|
111
|
+
|
|
112
|
+
::: tip
|
|
113
|
+
There are no decorators or naming conventions to learn: the class says what it is, and the rules
|
|
114
|
+
take it at its word.
|
|
115
|
+
:::
|
|
116
|
+
|
|
117
|
+
## Every import counts
|
|
118
|
+
|
|
119
|
+
The rules that check imports read every way a file can depend on another one, not only
|
|
120
|
+
`import … from`:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { Pool } from "pg";
|
|
124
|
+
export { Pool } from "pg";
|
|
125
|
+
type Pool = import("pg").Pool;
|
|
126
|
+
const pg = await import("pg");
|
|
127
|
+
const pg = require("pg");
|
|
128
|
+
import pg = require("pg");
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
A global declared by the project, in a `declare global` block, counts as an import of the file that
|
|
132
|
+
declares it.
|
|
133
|
+
|
|
134
|
+
An import the analysis cannot see through counts as a file outside the project: one that does not
|
|
135
|
+
resolve, such as a `.js` file without types, one whose path is computed at runtime, or one that is
|
|
136
|
+
ignored, such as a test file. No layer imports it: only the composition root and the files at the
|
|
137
|
+
root of `src/` may.
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
src/ordering/domain/services/pricing.service.ts
|
|
141
|
+
2 error layers/no-impure-domain: The domain imports
|
|
142
|
+
src/ordering/domain/services/db.spec.ts (ignored by the analysis):
|
|
143
|
+
it may only import the domain.
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Set the level of a rule
|
|
147
|
+
|
|
148
|
+
Every rule reports an `error` by default, and an error fails the check. In `alveolus.config.ts`,
|
|
149
|
+
lower a rule to `warn` or `info`, which report without failing, or turn it `off`, with its full
|
|
150
|
+
name:
|
|
151
|
+
|
|
152
|
+
```ts [alveolus.config.ts]
|
|
153
|
+
export default defineConfig({
|
|
154
|
+
boundedContexts: { ordering: "ordering" },
|
|
155
|
+
root: "src",
|
|
156
|
+
rules: { "tactical/no-misplaced-class": "off", "tactical/no-public-field": "warn" },
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`warn` and `info` are the way in on an existing project: a rule reports for a while, the team
|
|
161
|
+
fixes, then it becomes an error.
|
|
162
|
+
|
|
163
|
+
To turn one violation off where it stands, with a reason, write a disable comment above the line:
|
|
164
|
+
see [Getting started](../guide/getting-started.md#turn-a-violation-off). To adopt the rules on an
|
|
165
|
+
existing project without turning them off, record the current violations in a baseline: see
|
|
166
|
+
[Getting started](../guide/getting-started.md#adopt-it-on-an-existing-project).
|
|
167
|
+
|
|
168
|
+
Tests and their companions (`*.spec.ts`, `*.test.ts`, `*.e2e-spec.ts`, `*.fixture.ts`, `*.stories.ts`,
|
|
169
|
+
`__tests__/`, `__mocks__/`) are never checked, and production code may not
|
|
170
|
+
import them.
|
|
171
|
+
|
|
172
|
+
## What the rules cannot see
|
|
173
|
+
|
|
174
|
+
The rules read the code, not what it does at run time: a port whose adapter reads the views, an
|
|
175
|
+
interface shaped like an aggregate, or an anti-corruption layer that passes data through untouched
|
|
176
|
+
all look right. Each rule page lists its limits in a **Limits** section, with what to watch for in
|
|
177
|
+
review. A file that matches `ignore` in `alveolus.config.ts` is not analysed at all: review a
|
|
178
|
+
change to `ignore` as you would review a rule turned off.
|
|
179
|
+
|
|
180
|
+
## See also
|
|
181
|
+
|
|
182
|
+
- [Getting started](../guide/getting-started.md), to configure and run the checks
|
|
183
|
+
- [Project layout](../guide/project-layout.md), the layout the rules keep
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: a driving adapter calls the command and query handlers, and never reaches a repository, a port or an aggregate of the domain itself."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-driving-shortcut
|
|
6
|
+
|
|
7
|
+
A driving adapter calls the command and query handlers: it never reaches a repository, a port, an
|
|
8
|
+
aggregate or a domain service itself.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>layers/no-driving-shortcut</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 file of <code>driving/</code> that imports a <code>CommandRepository</code>, a <code>QueryRepository</code>, a <code>Port</code>, an <code>AggregateRoot</code>, an <code>Entity</code> or a <code>DomainService</code></dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every file in <code>driving/</code>, in every core bounded context and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"layers/no-driving-shortcut": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`OrdersController` injects `Orders`, loads the order, calls `place()` and saves it: the use case
|
|
21
|
+
now lives in the controller. The next entry point, a message consumer, copies those lines; the
|
|
22
|
+
transaction, the outbox and the events that `PlaceOrderHandler` handles are skipped, and the
|
|
23
|
+
rules that `no-foreign-command-dependency` keeps on handlers do not apply to a controller.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
A driving adapter translates a request into a command or a query, calls its handler, and
|
|
27
|
+
translates the `Result` into a response. The use case exists once, in the application.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Every import of a file in `driving/` that points to a file of the project:
|
|
33
|
+
|
|
34
|
+
| Imported class | Allowed |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| A `CommandHandler`, a `QueryHandler`, an `EventTranslator` | ✅ |
|
|
37
|
+
| A value object, an identifier, a domain error, a view, a representation | ✅ |
|
|
38
|
+
| A `CommandRepository`, a `QueryRepository`, a `Port` | ❌ |
|
|
39
|
+
| An `AggregateRoot`, an `Entity`, a `DomainService` | ❌ |
|
|
40
|
+
|
|
41
|
+
A class counts by what it extends: `Orders` extends `CommandRepository<Order>`. `import type`
|
|
42
|
+
counts too, since a dependency injected by type is a dependency. Every form of import counts, see
|
|
43
|
+
[Every import counts](../index.md#every-import-counts).
|
|
44
|
+
|
|
45
|
+
## What it reports
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/ordering/driving/http/orders.controller.ts
|
|
49
|
+
3 error layers/no-driving-shortcut: Imports Orders, a CommandRepository:
|
|
50
|
+
a driving adapter calls the command and query handlers, never
|
|
51
|
+
the ports, repositories or aggregates of the domain.
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Fix it
|
|
55
|
+
|
|
56
|
+
### Call the handler
|
|
57
|
+
|
|
58
|
+
So that the use case exists once, the controller receives the handler and passes it a command.
|
|
59
|
+
|
|
60
|
+
<div class="al-compare">
|
|
61
|
+
|
|
62
|
+
```ts [❌ Avoid: src/ordering/driving/http/orders.controller.ts]
|
|
63
|
+
export class OrdersController {
|
|
64
|
+
constructor(private readonly orders: Orders) {}
|
|
65
|
+
|
|
66
|
+
async place(id: string): Promise<void> {
|
|
67
|
+
const order = await this.orders.findById(new OrderId(id));
|
|
68
|
+
order?.place();
|
|
69
|
+
await this.orders.save(order);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
```ts [✅ Prefer: src/ordering/driving/http/orders.controller.ts]
|
|
75
|
+
export class OrdersController {
|
|
76
|
+
constructor(private readonly placeOrder: PlaceOrderHandler) {}
|
|
77
|
+
|
|
78
|
+
async place(id: string): Promise<void> {
|
|
79
|
+
const result = await this.placeOrder.handle({ orderId: id });
|
|
80
|
+
if (!result.ok) {
|
|
81
|
+
throw new BadRequestException(result.error.type);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
### Read through a query
|
|
90
|
+
|
|
91
|
+
So that a read goes through the same door, a controller that needs data calls a
|
|
92
|
+
[query handler](../../core/application/query-handlers.md), never a query repository.
|
|
93
|
+
|
|
94
|
+
## Limits
|
|
95
|
+
|
|
96
|
+
::: warning What the rule cannot see
|
|
97
|
+
- A handler injected and then bypassed: the controller may still receive a repository through a
|
|
98
|
+
framework token (`@Inject("ORDERS")`) typed as `unknown`. In review, a driving adapter has no
|
|
99
|
+
provider but handlers.
|
|
100
|
+
- What the driving adapter does with a value object or an error: those stay importable to map
|
|
101
|
+
requests and responses.
|
|
102
|
+
:::
|
|
103
|
+
|
|
104
|
+
## Turn it off
|
|
105
|
+
|
|
106
|
+
```ts [alveolus.config.ts]
|
|
107
|
+
rules: { "layers/no-driving-shortcut": "off" },
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
111
|
+
new entry points go through the handlers while you move the old use cases out of the controllers.
|
|
112
|
+
|
|
113
|
+
## See also
|
|
114
|
+
|
|
115
|
+
- [Command handlers](../../core/application/command-handlers.md) and
|
|
116
|
+
[Query handlers](../../core/application/query-handlers.md), what a driving adapter calls
|
|
117
|
+
- [`layers/no-outward-import`](./no-outward-import.md), what each layer may import
|
|
118
|
+
- [Project layout: layers](../../guide/project-layout.md#layers)
|
|
119
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: the domain layer depends on nothing but itself and @alveolus/core, with no ORM, framework or infrastructure import."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-impure-domain
|
|
6
|
+
|
|
7
|
+
The domain depends on nothing but itself: its own domain, the domain of the shared kernel and the
|
|
8
|
+
domain building blocks of `@alveolus/core`.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>layers/no-impure-domain</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>The domain importing a framework, a database, another layer or a package not allowed, using a global of the host, reading the clock or drawing a random value</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every file in <code>domain/</code>, in every core bounded context and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"layers/no-impure-domain": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
The `Order` aggregate carries TypeORM decorators so that it can be saved as is, and calls a mailer
|
|
21
|
+
when it is placed. Upgrading the ORM now means touching the business rules, and testing that an
|
|
22
|
+
empty order is refused needs a database and an SMTP server.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The domain imports nothing technical. Storage and email are [ports](../../core/domain/ports.md)
|
|
26
|
+
declared by the domain and implemented by driven adapters. The business rules change when the
|
|
27
|
+
business does, not when a library does, and run in a test without any infrastructure.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Every import of a file in `domain/`, in a bounded context or in the shared kernel:
|
|
33
|
+
|
|
34
|
+
| Import | Allowed when |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| A file of the project | It is in the `domain/` of the same context or of the shared kernel. |
|
|
37
|
+
| `@alveolus/core` | Every imported name is a domain building block or part of `Result`: `AggregateRoot`, `Entity`, `ValueObject`, `Identifier`, `DomainEvent`, `DomainError`, `DomainService`, `Port`, `Clock`, `IdGenerator`, `CommandRepository`, `QueryRepository`, `View`, `Result`, `ok`, `err`, `map`, `mapErr`, `andThen`, `combine` and their `Any…` types. |
|
|
38
|
+
| Any other package | It is listed in `domainDependencies`; when its entry lists names, every imported name is one of them. |
|
|
39
|
+
|
|
40
|
+
Importing from the root `@alveolus/core` is fine: the rule checks each imported name, not the path.
|
|
41
|
+
|
|
42
|
+
Every form of import counts, see [Every import counts](../index.md#every-import-counts).
|
|
43
|
+
|
|
44
|
+
### Globals
|
|
45
|
+
|
|
46
|
+
A global is used without an import, so the rule reads every global the domain uses:
|
|
47
|
+
|
|
48
|
+
| Global | Allowed when |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| An ECMAScript built-in: `Array`, `Map`, `JSON`, `Math`, `Promise`, `Intl`… | Always, except `Date.now()`, `new Date()` without argument, `Date()` and `Math.random()`, which read the clock or draw a random value. |
|
|
51
|
+
| A global of the host: `fetch`, `process`, `console`, `setTimeout`, `crypto`, a DOM type such as `Response`… | Never. |
|
|
52
|
+
| A global declared by the project, in a `declare global` block | As if the domain imported the file that declares it. |
|
|
53
|
+
|
|
54
|
+
The rule tells them apart by where they are declared: the ECMAScript library of TypeScript, the
|
|
55
|
+
types of the host (DOM, Node), or a file of the project. A local variable named `fetch` is not a
|
|
56
|
+
global.
|
|
57
|
+
|
|
58
|
+
## What it reports
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
src/ordering/domain/aggregates/order.aggregate.ts
|
|
62
|
+
1 error layers/no-impure-domain: The domain imports @nestjs/common:
|
|
63
|
+
add it to domainDependencies if the domain really needs it.
|
|
64
|
+
2 error layers/no-impure-domain: The domain imports UnitOfWork from
|
|
65
|
+
@alveolus/core: only domain building blocks and Result are allowed.
|
|
66
|
+
5 error layers/no-impure-domain: The domain imports
|
|
67
|
+
src/ordering/driven/smtp/adapters/mailer.adapter.ts
|
|
68
|
+
(ordering driven): it may only import the domain.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A global of the host, the clock and randomness:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
src/ordering/domain/services/pricing.service.ts
|
|
75
|
+
3 error layers/no-impure-domain: The domain uses fetch, a global of the
|
|
76
|
+
host: reach it through a port.
|
|
77
|
+
|
|
78
|
+
src/ordering/domain/aggregates/order.aggregate.ts
|
|
79
|
+
12 error layers/no-impure-domain: The domain reads the system clock with
|
|
80
|
+
Date.now: receive the time from the Clock port.
|
|
81
|
+
13 error layers/no-impure-domain: The domain draws a random value with
|
|
82
|
+
Math.random: receive it from a port, such as IdGenerator.
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A name not allowed from a restricted package is reported as well:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
layers/no-impure-domain: The domain imports format from date-fns:
|
|
89
|
+
domainDependencies only allows addDays, isBefore.
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Fix it
|
|
93
|
+
|
|
94
|
+
### Keep infrastructure behind a port
|
|
95
|
+
|
|
96
|
+
So that the business rules survive a change of database, framework or mail provider, the domain
|
|
97
|
+
declares what it needs as a port, and a driven adapter implements it. Transactions belong to the
|
|
98
|
+
command handler, not to the domain.
|
|
99
|
+
|
|
100
|
+
<div class="al-compare">
|
|
101
|
+
|
|
102
|
+
```ts [❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
103
|
+
import { Injectable } from "@nestjs/common";
|
|
104
|
+
import { AggregateRoot, UnitOfWork } from "@alveolus/core";
|
|
105
|
+
import { Column, Entity } from "typeorm";
|
|
106
|
+
|
|
107
|
+
import { Mailer } from "../../driven/smtp/adapters/mailer.adapter";
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```ts [✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
111
|
+
import { AggregateRoot, err, ok, type Result } from "@alveolus/core";
|
|
112
|
+
|
|
113
|
+
import { Money } from "../../../shared-kernel/domain/value-objects/money.value-object";
|
|
114
|
+
import { InvalidTotal } from "../errors/invalid-total.error";
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
</div>
|
|
118
|
+
|
|
119
|
+
### Receive the time and random values
|
|
120
|
+
|
|
121
|
+
So that a rule about dates gives the same answer in a test as in production, the domain never
|
|
122
|
+
reads the clock or draws a random value itself. The command handler asks the
|
|
123
|
+
[`Clock` and `IdGenerator` ports](../../core/domain/ports.md) and passes the values in.
|
|
124
|
+
|
|
125
|
+
<div class="al-compare">
|
|
126
|
+
|
|
127
|
+
```ts [❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
128
|
+
public place(): Result<void, never> {
|
|
129
|
+
this.placedAt = new Date();
|
|
130
|
+
return ok(undefined);
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```ts [✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
135
|
+
public place(at: Date): Result<void, never> {
|
|
136
|
+
this.placedAt = at;
|
|
137
|
+
return ok(undefined);
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
### Map storage outside the domain
|
|
144
|
+
|
|
145
|
+
So that the aggregate is not shaped by its table, it exposes a snapshot, and the repository
|
|
146
|
+
adapter maps that snapshot to its storage. See [Aggregates](../../core/domain/aggregates.md).
|
|
147
|
+
|
|
148
|
+
## Allow a package
|
|
149
|
+
|
|
150
|
+
Some packages belong in a domain, such as a decimal library for money. Declare them, with `true`
|
|
151
|
+
to allow everything they export, or with the names you allow:
|
|
152
|
+
|
|
153
|
+
```ts [alveolus.config.ts]
|
|
154
|
+
export default defineConfig({
|
|
155
|
+
boundedContexts: { ordering: "ordering" },
|
|
156
|
+
domainDependencies: {
|
|
157
|
+
"date-fns": ["addDays", "isBefore"],
|
|
158
|
+
"decimal.js": true,
|
|
159
|
+
},
|
|
160
|
+
root: "src",
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The packages of `domainDependencies` are allowed in the application too.
|
|
165
|
+
|
|
166
|
+
## Limits
|
|
167
|
+
|
|
168
|
+
::: warning What the rule cannot see
|
|
169
|
+
- `domainDependencies` is not transitive: a package you allow may import anything itself. Allow
|
|
170
|
+
small, pure packages, such as a decimal or a date library.
|
|
171
|
+
- A file that matches `ignore` in `alveolus.config.ts` is not analysed at all, and the domain may
|
|
172
|
+
import it: review a change to `ignore` as you would review a rule turned off.
|
|
173
|
+
:::
|
|
174
|
+
|
|
175
|
+
## Turn it off
|
|
176
|
+
|
|
177
|
+
```ts [alveolus.config.ts]
|
|
178
|
+
rules: { "layers/no-impure-domain": "off" },
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
182
|
+
new code keeps the domain pure while you clean up the old one.
|
|
183
|
+
|
|
184
|
+
## See also
|
|
185
|
+
|
|
186
|
+
- [Project layout: layers](../../guide/project-layout.md#layers)
|
|
187
|
+
- [Ports](../../core/domain/ports.md), to reach infrastructure from the domain
|
|
188
|
+
- [`layers/no-outward-import`](./no-outward-import.md), the same idea for the other layers
|
|
189
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
root: "src",
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The packages of `domainDependencies` are allowed in the application too.
|
|
159
|
+
|
|
160
|
+
## Limits
|
|
161
|
+
|
|
162
|
+
::: warning What the rule cannot see
|
|
163
|
+
- Below its technology, an adapter layer may hold any folder: `driving/http/controllers/v1/` is
|
|
164
|
+
fine. What those folders contain is checked by
|
|
165
|
+
[`tactical/no-loose-code`](../tactical/no-loose-code.md): classes only.
|
|
166
|
+
- A file that matches `ignore` in `alveolus.config.ts` is not analysed at all. Review a change to
|
|
167
|
+
`ignore` as you would review a rule turned off.
|
|
168
|
+
:::
|
|
169
|
+
|
|
170
|
+
## Turn it off
|
|
171
|
+
|
|
172
|
+
```ts [alveolus.config.ts]
|
|
173
|
+
rules: { "layers/no-outward-import": "off" },
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
177
|
+
new code keeps the arrows inwards while you fix the old ones.
|
|
178
|
+
|
|
179
|
+
## See also
|
|
180
|
+
|
|
181
|
+
- [Project layout: who may import what](../../guide/project-layout.md#who-may-import-what)
|
|
182
|
+
- [Integrations](../../integrations/index.md), to build the classes in the composition root
|
|
183
|
+
- [`layers/no-impure-domain`](./no-impure-domain.md), the same idea for the domain
|
|
184
|
+
- [Rules](../index.md), every rule by category
|