@alveolus/arch 0.3.0 → 0.5.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 +5 -0
- package/dist/bin.mjs +1 -1
- package/dist/{docs-DsQHpTtV.mjs → docs-B1Tzkjrn.mjs} +359 -49
- package/dist/docs-B1Tzkjrn.mjs.map +1 -0
- package/dist/index.d.mts +49 -15
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -1
- package/docs/guide/existing-project.md +6 -3
- package/docs/guide/getting-started.md +14 -3
- package/docs/guide/project-layout.md +3 -1
- package/docs/rules/index.md +14 -3
- package/docs/rules/layers/no-impure-domain.md +2 -0
- package/docs/rules/layers/no-outward-import.md +9 -3
- package/docs/rules/strategic/no-cross-context-import.md +35 -2
- package/docs/rules/strategic/no-fat-shared-kernel.md +1 -0
- package/docs/rules/strategic/no-shared-state.md +100 -0
- package/docs/rules/strategic/no-unmapped-context.md +61 -25
- package/docs/rules/tactical/no-foreign-query-dependency.md +98 -3
- package/package.json +1 -1
- package/dist/docs-DsQHpTtV.mjs.map +0 -1
|
@@ -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
|
|
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
|
|
|
@@ -24,40 +24,68 @@ extracted or rewritten without the other. In a system that lives for years, that
|
|
|
24
24
|
turns "change this module" into "rewrite the application".
|
|
25
25
|
|
|
26
26
|
::: tip The fix
|
|
27
|
-
Write the context map, as DDD asks: which context is upstream of which.
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
Write the context map, as DDD asks: which context is upstream of which. `alveolus.config.ts`
|
|
28
|
+
requires it, so the code can no longer stray from it. A dependency that goes against the map is
|
|
29
|
+
reversed: `Ledger` publishes an event, `Payments` reacts. Adding a line to the map is the other
|
|
30
|
+
way out, and it is a strategic decision: take it in a review, not in a fix.
|
|
30
31
|
:::
|
|
31
32
|
|
|
32
33
|
## What it checks
|
|
33
34
|
|
|
34
35
|
Every import from a file of one bounded context to a file of another, open host service or
|
|
35
|
-
composition root alike:
|
|
36
|
+
composition root alike: the importing context lists the imported one under `consumes` in
|
|
37
|
+
`contextMap`.
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
The map itself is checked when the configuration loads, before any rule runs: a context left out
|
|
40
|
+
of the map, a context the map names that `boundedContexts` does not declare, a context that
|
|
41
|
+
consumes itself, or a cycle, is an error. Two contexts that depend on each other are therefore
|
|
42
|
+
never allowed, whichever way the code is written.
|
|
41
43
|
|
|
42
44
|
Imports of the shared kernel are not consumptions: every context may import it.
|
|
43
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 e-money:
|
|
51
|
+
|
|
52
|
+
```ts [src/app.module.ts]
|
|
53
|
+
this.ledger = new LedgerModule({
|
|
54
|
+
redemptions: () => this.emoney.commands.requestRedemption,
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
No file of ledger imports e-money, 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
|
+
|
|
44
66
|
## What it reports
|
|
45
67
|
|
|
46
68
|
```
|
|
47
69
|
src/ledger/driven/payments/adapters/payment-status.adapter.ts
|
|
48
70
|
2 error strategic/no-unmapped-context: ledger consumes payments, which the
|
|
49
|
-
context map does not allow:
|
|
50
|
-
|
|
71
|
+
context map does not allow: reverse the dependency, or if ledger
|
|
72
|
+
really is downstream of payments, add payments to
|
|
73
|
+
contextMap.ledger.consumes.
|
|
74
|
+
|
|
75
|
+
src/app.module.ts
|
|
76
|
+
14 error strategic/no-unmapped-context: ledger receives
|
|
77
|
+
this.emoney.commands.requestRedemption from emoney here, which the
|
|
78
|
+
context map does not allow: reverse the dependency, or if ledger
|
|
79
|
+
really is downstream of emoney, add emoney to
|
|
80
|
+
contextMap.ledger.consumes.
|
|
51
81
|
```
|
|
52
82
|
|
|
53
|
-
|
|
83
|
+
A map that would allow it is refused before the check:
|
|
54
84
|
|
|
55
85
|
```
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
no longer change alone; declare a contextMap and reverse one
|
|
60
|
-
dependency.
|
|
86
|
+
Invalid configuration in alveolus.config.ts:
|
|
87
|
+
contextMap has a cycle: ledger → payments → ledger. Two contexts that depend
|
|
88
|
+
on each other can no longer change alone: reverse one dependency.
|
|
61
89
|
```
|
|
62
90
|
|
|
63
91
|
## Fix it
|
|
@@ -65,21 +93,23 @@ src/ledger/driven/payments/adapters/payment-status.adapter.ts
|
|
|
65
93
|
### Declare the context map
|
|
66
94
|
|
|
67
95
|
So that the direction of every dependency is a decision, not an accident, list for each context
|
|
68
|
-
the ones it consumes:
|
|
96
|
+
the ones it consumes. Each line reads as a sentence: `payments` consumes `ledger` and `customers`.
|
|
69
97
|
|
|
70
98
|
```ts [alveolus.config.ts]
|
|
71
99
|
export default defineConfig({
|
|
72
100
|
boundedContexts: { customers: "customers", ledger: "ledger", payments: "payments" },
|
|
73
101
|
contextMap: {
|
|
74
|
-
customers: [],
|
|
75
|
-
ledger: ["customers"],
|
|
76
|
-
payments: ["ledger", "customers"],
|
|
102
|
+
customers: { consumes: [] },
|
|
103
|
+
ledger: { consumes: ["customers"] },
|
|
104
|
+
payments: { consumes: ["ledger", "customers"] },
|
|
77
105
|
},
|
|
78
106
|
root: "src",
|
|
107
|
+
subdomains: { core: ["ledger", "payments"], generic: ["customers"] },
|
|
79
108
|
});
|
|
80
109
|
```
|
|
81
110
|
|
|
82
|
-
|
|
111
|
+
Every context is in the map. `consumes: []` is a decision too: that context goes its separate
|
|
112
|
+
way, and the day it needs another one, the import is reported and the map is updated on purpose.
|
|
83
113
|
|
|
84
114
|
### Reverse a dependency with an event
|
|
85
115
|
|
|
@@ -91,8 +121,14 @@ So that `Ledger` stays upstream, it does not ask `Payments` anything: it publish
|
|
|
91
121
|
|
|
92
122
|
::: warning What the rule cannot see
|
|
93
123
|
- A dependency that goes through the database, a queue or an HTTP call to another context's API
|
|
94
|
-
written as a string: the map covers imports. In review, every consumption of
|
|
95
|
-
is an import of its open host service.
|
|
124
|
+
written as a string: the map covers imports and the wiring. In review, every consumption of
|
|
125
|
+
another context is an import of its open host service.
|
|
126
|
+
- A value whose type is erased on the way: a cast (`as unknown as Handler`, `any`) in the
|
|
127
|
+
composition root, or a container token written as a string, such as NestJS
|
|
128
|
+
`{ provide: "redemptions", useFactory: … }`. In review, the composition root holds no cast, and
|
|
129
|
+
a token is the abstract class of a port.
|
|
130
|
+
- With the rule off, the wiring is no longer checked against the map, even though
|
|
131
|
+
[`strategic/no-cross-context-import`](./no-cross-context-import.md) still checks what crosses.
|
|
96
132
|
:::
|
|
97
133
|
|
|
98
134
|
## Turn it off
|
|
@@ -17,12 +17,20 @@ A query handler receives what reads: nothing that writes or changes state.
|
|
|
17
17
|
## Why
|
|
18
18
|
|
|
19
19
|
`GetOrderSummaryHandler` receives the unit of work: a read can now change state, and the caller who
|
|
20
|
-
asked a question gets a side effect too. A command handler
|
|
21
|
-
|
|
20
|
+
asked a question gets a side effect too. A command handler injected into it does the same, one
|
|
21
|
+
step removed.
|
|
22
|
+
|
|
23
|
+
A domain service is different: it is pure, so injecting it changes nothing. What it changes is
|
|
24
|
+
where the business rule runs. `GetSafeguardingReconciliationHandler` receives
|
|
25
|
+
`SafeguardingReconciliation` and computes the reconciliation at each read: two readers at two
|
|
26
|
+
moments see two different results, nothing records which one was reported, and the rule now runs
|
|
27
|
+
on two paths, the command's and the query's, that drift apart. The read side delivers data shaped
|
|
28
|
+
for the reader; the domain's behaviour runs on the write side, once, and leaves a fact.
|
|
22
29
|
|
|
23
30
|
::: tip The fix
|
|
24
31
|
A query reads a view through a query repository, and writes nothing. A query that seems to need a
|
|
25
|
-
write is a command, or a command followed by a query.
|
|
32
|
+
write is a command, or a command followed by a query. A query that seems to need a domain service
|
|
33
|
+
is reading a fact nobody recorded, or holding a calculation that belongs to a value object.
|
|
26
34
|
:::
|
|
27
35
|
|
|
28
36
|
## What it checks
|
|
@@ -78,6 +86,91 @@ constructor(private readonly summaries: OrderSummaries) {
|
|
|
78
86
|
|
|
79
87
|
</div>
|
|
80
88
|
|
|
89
|
+
### Record the fact, then read it
|
|
90
|
+
|
|
91
|
+
So that a result the reader relies on exists once, with its date, a domain service runs in a
|
|
92
|
+
command handler that records its outcome, and the query reads the record. A reconciliation, a
|
|
93
|
+
regulatory figure, a score: when the reader asks "what was it", the answer is a fact to keep, not
|
|
94
|
+
a calculation to redo.
|
|
95
|
+
|
|
96
|
+
<div class="al-compare">
|
|
97
|
+
|
|
98
|
+
```ts [❌ Avoid: src/safeguarding/application/queries/get-reconciliation.query.ts]
|
|
99
|
+
constructor(
|
|
100
|
+
private readonly balances: SafeguardingBalances,
|
|
101
|
+
private readonly reconciliation: SafeguardingReconciliation,
|
|
102
|
+
) {
|
|
103
|
+
super();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async handle(query: GetReconciliation): Promise<Result<ReconciliationView, NotFound>> {
|
|
107
|
+
const balances = await this.balances.on(query.date);
|
|
108
|
+
return ok(this.reconciliation.reconcile(balances));
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```ts [✅ Prefer: src/safeguarding/application/commands/reconcile-safeguarding.command.ts]
|
|
113
|
+
constructor(
|
|
114
|
+
private readonly accounts: SafeguardingAccounts,
|
|
115
|
+
private readonly reconciliation: SafeguardingReconciliation,
|
|
116
|
+
private readonly unitOfWork: UnitOfWork,
|
|
117
|
+
) {
|
|
118
|
+
super();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async handle(command: ReconcileSafeguarding): Promise<Result<void, NotFound>> {
|
|
122
|
+
const account = await this.accounts.of(command.accountId);
|
|
123
|
+
account.reconcile(this.reconciliation.reconcile(account.balances()), command.at);
|
|
124
|
+
await this.unitOfWork.commit();
|
|
125
|
+
return ok();
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
The query handler then receives `Reconciliations`, a query repository, and returns the
|
|
132
|
+
reconciliation of the date asked. The aggregate records the outcome, so the domain service keeps
|
|
133
|
+
one caller.
|
|
134
|
+
|
|
135
|
+
### Move a calculation into a value object
|
|
136
|
+
|
|
137
|
+
So that a figure derived from the values of a view is computed where values are computed, the
|
|
138
|
+
calculation becomes a static factory of a [value object](../../core/domain/value-objects.md),
|
|
139
|
+
which a query may use: a projection, a conversion, a total. Nothing is recorded because nothing
|
|
140
|
+
happened.
|
|
141
|
+
|
|
142
|
+
<div class="al-compare">
|
|
143
|
+
|
|
144
|
+
```ts [❌ Avoid: src/safeguarding/application/queries/get-own-funds-requirement.query.ts]
|
|
145
|
+
constructor(
|
|
146
|
+
private readonly figures: SafeguardingFigures,
|
|
147
|
+
private readonly calculator: OwnFundsRequirementCalculator,
|
|
148
|
+
) {
|
|
149
|
+
super();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async handle(query: GetOwnFundsRequirement): Promise<Result<OwnFundsRequirementView, NotFound>> {
|
|
153
|
+
const figures = await this.figures.of(query.firmId);
|
|
154
|
+
return ok({ amount: this.calculator.compute(figures) });
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```ts [✅ Prefer: src/safeguarding/application/queries/get-own-funds-requirement.query.ts]
|
|
159
|
+
constructor(private readonly figures: SafeguardingFigures) {
|
|
160
|
+
super();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async handle(query: GetOwnFundsRequirement): Promise<Result<OwnFundsRequirementView, NotFound>> {
|
|
164
|
+
const figures = await this.figures.of(query.firmId);
|
|
165
|
+
return ok({ amount: OwnFundsRequirement.of(figures).amount });
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
Which of the two? If the reader asks for the figure as it was declared or decided, record it. If
|
|
172
|
+
the reader asks what the figure would be from the values on the screen, calculate it.
|
|
173
|
+
|
|
81
174
|
## Limits
|
|
82
175
|
|
|
83
176
|
::: warning What the rule cannot see
|
|
@@ -101,6 +194,8 @@ new queries keep to reading while you split the old ones.
|
|
|
101
194
|
- [Query handlers](../../core/application/query-handlers.md), what is checked
|
|
102
195
|
- [Repositories](../../core/domain/repositories.md) and [Views](../../core/domain/views.md), what
|
|
103
196
|
a query reads
|
|
197
|
+
- [Domain services](../../core/domain/domain-services.md), called by the command handler, and
|
|
198
|
+
[value objects](../../core/domain/value-objects.md), the home of a calculation
|
|
104
199
|
- [`tactical/no-foreign-command-dependency`](./no-foreign-command-dependency.md), the same list for
|
|
105
200
|
command handlers
|
|
106
201
|
- [Rules](../index.md), every rule by category
|