@alveolus/arch 0.4.0 → 0.6.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/dist/bin.mjs +1 -1
- package/dist/{docs-DcFgskuN.mjs → docs-B6M9DxTM.mjs} +732 -15
- package/dist/docs-B6M9DxTM.mjs.map +1 -0
- package/dist/index.d.mts +68 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/docs/core/strategic/open-host-services.md +1 -1
- package/docs/guide/project-layout.md +3 -1
- package/docs/rules/index.md +17 -4
- package/docs/rules/layers/no-outward-import.md +7 -3
- package/docs/rules/strategic/no-cross-context-import.md +64 -2
- package/docs/rules/strategic/no-fat-shared-kernel.md +1 -0
- package/docs/rules/strategic/no-leaky-host-service.md +59 -6
- package/docs/rules/strategic/no-shared-state.md +126 -0
- package/docs/rules/strategic/no-unmapped-context.md +46 -7
- package/package.json +1 -1
- package/dist/docs-DcFgskuN.mjs.map +0 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: the shared kernel shares a model, not state; a static field that holds state would let two contexts talk where the context map does not show it."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-shared-state
|
|
6
|
+
|
|
7
|
+
The shared kernel shares a model, never state. A static field that the code can change is reached
|
|
8
|
+
by every context at once: a channel between them that no import and no context map shows.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>strategic/no-shared-state</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 static field of the shared kernel without <code>readonly</code>, or holding a collection</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-shared-state": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
The catalog needs to cancel the orders of a withdrawn product, and the context map says the
|
|
21
|
+
catalog consumes nothing. Someone adds a `ServiceRegistry` to the shared kernel: ordering registers
|
|
22
|
+
its handler under a name, the catalog resolves it. No file of the catalog imports ordering, every
|
|
23
|
+
import rule passes, and the catalog now depends on ordering in a way nobody decided.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
Contexts talk through an [open host service](../../core/strategic/open-host-services.md), consumed
|
|
27
|
+
in an [anti-corruption layer](../../core/strategic/anti-corruption-layers.md), and the
|
|
28
|
+
[context map](./no-unmapped-context.md) says who consumes whom. The shared kernel holds what both
|
|
29
|
+
contexts mean the same way: value objects, identifiers, ports. Constants are fine; a place to put
|
|
30
|
+
things is not.
|
|
31
|
+
:::
|
|
32
|
+
|
|
33
|
+
## What it checks
|
|
34
|
+
|
|
35
|
+
Every static field of a class in the shared kernel:
|
|
36
|
+
|
|
37
|
+
| Static field | Allowed |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `static readonly ZERO = new Money({ amount: 0 })` | ✅ |
|
|
40
|
+
| `static readonly PRECISION = 2` | ✅ |
|
|
41
|
+
| `static readonly CURRENCIES: readonly string[] = ["EUR"]`, a `ReadonlyMap`, a `ReadonlySet` | ✅ |
|
|
42
|
+
| `static readonly NONE = new CustomerId("")`: an identifier | ✅ |
|
|
43
|
+
| `static readonly RATE = new Decimal("1.1")`: a class of a package listed in `domainDependencies` | ✅ |
|
|
44
|
+
| `static count = 0`: without `readonly` | ❌ |
|
|
45
|
+
| `static readonly services = new Map()`: a `Map`, a `Set`, a `WeakMap`, a `WeakSet` | ❌ |
|
|
46
|
+
| `static readonly names: string[] = []`: an array that is not `readonly` | ❌ |
|
|
47
|
+
| `static readonly byId: Record<string, Handler> = {}`: an index signature | ❌ |
|
|
48
|
+
| `static readonly options = { strict: true }`: an object literal | ❌ |
|
|
49
|
+
| `static readonly shared = new ServiceDirectory()`: a class that is not a value, such as a singleton | ❌ |
|
|
50
|
+
| `static readonly bus = new EventEmitter()`: a class of a package outside `domainDependencies` | ❌ |
|
|
51
|
+
| `static readonly resolve = makeResolver()`: a function, which can close over any state | ❌ |
|
|
52
|
+
|
|
53
|
+
A static field holds a value: a primitive, a value object, an identifier, a class of a package
|
|
54
|
+
listed in `domainDependencies`, or a `readonly` collection of them. Static methods are not fields:
|
|
55
|
+
`static of(amount: number)` is fine.
|
|
56
|
+
|
|
57
|
+
Instance fields are not checked: an adapter of the shared kernel may hold its own state. When each
|
|
58
|
+
context builds its own instance, nobody else reaches it; when the composition root at the root of
|
|
59
|
+
`src/` hands the same instance to two contexts, it is a channel, see [Limits](#limits).
|
|
60
|
+
|
|
61
|
+
Module-level state, such as `let current` or `const services = new Map()` at the top of a file, is
|
|
62
|
+
reported by [`tactical/no-loose-code`](../tactical/no-loose-code.md).
|
|
63
|
+
|
|
64
|
+
## What it reports
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
src/shared-kernel/driven/memory/registry/service-registry.ts
|
|
68
|
+
2 error strategic/no-shared-state: ServiceRegistry.services holds a
|
|
69
|
+
collection in a static field: every context reaches the same one, a
|
|
70
|
+
channel the context map does not show. The shared kernel shares a
|
|
71
|
+
model, not state: integrate through an open host service.
|
|
72
|
+
3 error strategic/no-shared-state: ServiceRegistry.shared holds a
|
|
73
|
+
ServiceRegistry in a static field, which can keep state every context
|
|
74
|
+
reaches, a channel the context map does not show. A static field of
|
|
75
|
+
the shared kernel holds a value: a primitive, a value object, an
|
|
76
|
+
identifier, a class of domainDependencies, or a readonly collection
|
|
77
|
+
of them.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Fix it
|
|
81
|
+
|
|
82
|
+
### Integrate through an open host service
|
|
83
|
+
|
|
84
|
+
So that the dependency is decided and visible, the upstream context exposes an open host service,
|
|
85
|
+
the downstream context adds it to `contextMap.<downstream>.consumes` and calls it from an
|
|
86
|
+
anti-corruption layer. The composition root passes the service; no registry is needed.
|
|
87
|
+
|
|
88
|
+
### Keep a constant constant
|
|
89
|
+
|
|
90
|
+
So that a constant cannot become a channel, make it `readonly` and give it an immutable type: a
|
|
91
|
+
value object, a primitive, a `readonly` array, a `ReadonlyMap`.
|
|
92
|
+
|
|
93
|
+
### Build services in the composition root
|
|
94
|
+
|
|
95
|
+
So that every context gets the instance the composition root decides, a clock, a bus or a
|
|
96
|
+
directory is built there and passed to the constructors that need it, not kept in a static
|
|
97
|
+
`instance` field.
|
|
98
|
+
|
|
99
|
+
## Limits
|
|
100
|
+
|
|
101
|
+
::: warning What the rule cannot see
|
|
102
|
+
- A value object or a class of `domainDependencies` is trusted to be immutable: a value object
|
|
103
|
+
that keeps a `Map` in a private field passes. In review, a value object changes by returning a
|
|
104
|
+
new one.
|
|
105
|
+
- A `Readonly<Record<…>>` counts as a collection, because it has an index signature: use a
|
|
106
|
+
`ReadonlyMap` for a constant dictionary.
|
|
107
|
+
- An instance handed to two contexts by the composition root at the root of `src/` is not checked:
|
|
108
|
+
the database and the outbox are shared that way on purpose, and a registry passed the same way
|
|
109
|
+
goes unseen. In review, the root passes the same instance to several contexts only for the
|
|
110
|
+
infrastructure every context needs: the database, the outbox, the clock.
|
|
111
|
+
- State held outside the shared kernel is not checked: a package with a global container, such as
|
|
112
|
+
the default container of a dependency injection library. A name of `globalThis` that no file
|
|
113
|
+
declares is reported by [`strategic/no-cross-context-import`](./no-cross-context-import.md).
|
|
114
|
+
:::
|
|
115
|
+
|
|
116
|
+
## Turn it off
|
|
117
|
+
|
|
118
|
+
```ts [alveolus.config.ts]
|
|
119
|
+
rules: { "strategic/no-shared-state": "off" },
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## See also
|
|
123
|
+
|
|
124
|
+
- [`strategic/no-fat-shared-kernel`](./no-fat-shared-kernel.md), what the shared kernel holds
|
|
125
|
+
- [`strategic/no-unmapped-context`](./no-unmapped-context.md), the relations between contexts
|
|
126
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -10,8 +10,8 @@ never depend on each other.
|
|
|
10
10
|
<dl class="al-glance">
|
|
11
11
|
<dt>Rule</dt><dd><code>strategic/no-unmapped-context</code></dd>
|
|
12
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</dd>
|
|
14
|
-
<dt>Applies to</dt><dd>Every file of every bounded context
|
|
13
|
+
<dt>Reports</dt><dd>An import of another context, or a value of another context given in the wiring, that <code>contextMap</code> does not allow</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every file of every bounded context; for the wiring, the composition roots and the files at the root of <code>src/</code></dd>
|
|
15
15
|
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"strategic/no-unmapped-context": "off"</code></a></dd>
|
|
16
16
|
</dl>
|
|
17
17
|
|
|
@@ -43,14 +43,43 @@ never allowed, whichever way the code is written.
|
|
|
43
43
|
|
|
44
44
|
Imports of the shared kernel are not consumptions: every context may import it.
|
|
45
45
|
|
|
46
|
+
### The wiring counts too
|
|
47
|
+
|
|
48
|
+
A context can consume another one without importing it: `app.module.ts` sees every module, and
|
|
49
|
+
can hand a value of one context to another. Ledger declares a port, its adapter calls whatever it
|
|
50
|
+
is given, and the root composition gives it a handler of payments:
|
|
51
|
+
|
|
52
|
+
```ts [src/app.module.ts]
|
|
53
|
+
this.ledger = new LedgerModule({
|
|
54
|
+
transfers: () => this.payments.commands.settleTransfer,
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
No file of ledger imports payments, yet ledger now consumes it. In the composition roots and the
|
|
59
|
+
files at the root of `src/`, every value given to a class, a function or a field of one context
|
|
60
|
+
(an argument, a property of an object, the body of an arrow function, an assignment, a variable
|
|
61
|
+
typed by that context) is read: when it comes from another context, by where it is declared or by
|
|
62
|
+
its type, the receiving context consumes that one, and the map must say so. A module handed whole,
|
|
63
|
+
such as `new PaymentsModule(this.ledger)`, is not a consumption yet: what that module then takes
|
|
64
|
+
from it is.
|
|
65
|
+
|
|
46
66
|
## What it reports
|
|
47
67
|
|
|
48
68
|
```
|
|
49
69
|
src/ledger/driven/payments/adapters/payment-status.adapter.ts
|
|
50
70
|
2 error strategic/no-unmapped-context: ledger consumes payments, which the
|
|
51
|
-
context map does not allow: reverse the dependency
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
context map does not allow: reverse the dependency with an
|
|
72
|
+
integration event that ledger publishes and payments subscribes to,
|
|
73
|
+
not with a callback; or if ledger really is downstream of payments,
|
|
74
|
+
add payments to contextMap.ledger.consumes.
|
|
75
|
+
|
|
76
|
+
src/app.module.ts
|
|
77
|
+
14 error strategic/no-unmapped-context: ledger receives
|
|
78
|
+
this.payments.commands.settleTransfer from payments here, which the
|
|
79
|
+
context map does not allow: reverse the dependency with an
|
|
80
|
+
integration event that ledger publishes and payments subscribes to,
|
|
81
|
+
not with a callback; or if ledger really is downstream of payments,
|
|
82
|
+
add payments to contextMap.ledger.consumes.
|
|
54
83
|
```
|
|
55
84
|
|
|
56
85
|
A map that would allow it is refused before the check:
|
|
@@ -90,12 +119,22 @@ So that `Ledger` stays upstream, it does not ask `Payments` anything: it publish
|
|
|
90
119
|
`TransferSettled` in its [published language](../../core/strategic/published-language.md), and
|
|
91
120
|
`Payments` reacts to it.
|
|
92
121
|
|
|
122
|
+
A callback is not a reversal. `Ledger` calling a function that `Payments` registered on its open
|
|
123
|
+
host service still runs code of `Payments` when `Ledger` decides: the dependency is the same, only
|
|
124
|
+
hidden from the map. An event carries data, and `Ledger` does not know who reacts.
|
|
125
|
+
|
|
93
126
|
## Limits
|
|
94
127
|
|
|
95
128
|
::: warning What the rule cannot see
|
|
96
129
|
- A dependency that goes through the database, a queue or an HTTP call to another context's API
|
|
97
|
-
written as a string: the map covers imports. In review, every consumption of
|
|
98
|
-
is an import of its open host service.
|
|
130
|
+
written as a string: the map covers imports and the wiring. In review, every consumption of
|
|
131
|
+
another context is an import of its open host service.
|
|
132
|
+
- A value whose type is erased on the way: a cast (`as unknown as Handler`, `any`) in the
|
|
133
|
+
composition root, or a container token written as a string, such as NestJS
|
|
134
|
+
`{ provide: "transfers", useFactory: … }`. In review, the composition root holds no cast, and
|
|
135
|
+
a token is the abstract class of a port.
|
|
136
|
+
- With the rule off, the wiring is no longer checked against the map, even though
|
|
137
|
+
[`strategic/no-cross-context-import`](./no-cross-context-import.md) still checks what crosses.
|
|
99
138
|
:::
|
|
100
139
|
|
|
101
140
|
## Turn it off
|