@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.
@@ -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; without a map, an import that closes a cycle between contexts</dd>
14
- <dt>Applies to</dt><dd>Every file of every bounded context</dd>
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. Declare it in
28
- `alveolus.config.ts`, and the code can no longer stray from it. A dependency that goes against
29
- the map is reversed: `Ledger` publishes an event, `Payments` reacts.
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
- <div class="al-cards al-cards-2">
38
- <div class="al-card"><span class="al-card-title">With a context map</span>The importing context lists the imported one in <code>contextMap</code>. The map itself is checked when the configuration loads: an unknown context or a cycle is an error.</div>
39
- <div class="al-card"><span class="al-card-title">Without a context map</span>The imports observed form no cycle. An import that closes one is reported at both ends.</div>
40
- </div>
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: add payments to contextMap.ledger, or
50
- reverse the dependency.
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
- Without a map:
83
+ A map that would allow it is refused before the check:
54
84
 
55
85
  ```
56
- src/ledger/driven/payments/adapters/payment-status.adapter.ts
57
- 2 error strategic/no-unmapped-context: ledger consumes payments, which
58
- consumes ledger back: two contexts that depend on each other can
59
- no longer change alone; declare a contextMap and reverse one
60
- dependency.
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
- A context absent from the map consumes nothing.
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 another context
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 or a domain service injected into it
21
- does the same, one step removed.
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
package/package.json CHANGED
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "sideEffects": false,
50
50
  "type": "module",
51
- "version": "0.3.0",
51
+ "version": "0.5.0",
52
52
  "scripts": {
53
53
  "build": "tsdown && biome check --write package.json",
54
54
  "typecheck": "tsc -p tsconfig.json"