@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,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: a bounded context consumes only the contexts its context map declares, and two contexts never depend on each other."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-unmapped-context
|
|
6
|
+
|
|
7
|
+
A bounded context consumes the contexts its context map declares, and nothing else: two contexts
|
|
8
|
+
never depend on each other.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>strategic/no-unmapped-context</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 of another context that <code>contextMap</code> does not allow</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every file of every bounded context</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"strategic/no-unmapped-context": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`Payments` reads balances from `Ledger`, through its open host service, as it should. A year
|
|
21
|
+
later `Ledger` asks `Payments` whether a transfer is pending, through *its* open host service.
|
|
22
|
+
Each import is clean on its own; together they tie the two contexts: neither can be deployed,
|
|
23
|
+
extracted or rewritten without the other. In a system that lives for years, that knot is what
|
|
24
|
+
turns "change this module" into "rewrite the application".
|
|
25
|
+
|
|
26
|
+
::: tip The fix
|
|
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.
|
|
31
|
+
:::
|
|
32
|
+
|
|
33
|
+
## What it checks
|
|
34
|
+
|
|
35
|
+
Every import from a file of one bounded context to a file of another, open host service or
|
|
36
|
+
composition root alike: the importing context lists the imported one under `consumes` in
|
|
37
|
+
`contextMap`.
|
|
38
|
+
|
|
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.
|
|
43
|
+
|
|
44
|
+
Imports of the shared kernel are not consumptions: every context may import it.
|
|
45
|
+
|
|
46
|
+
## What it reports
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/ledger/driven/payments/adapters/payment-status.adapter.ts
|
|
50
|
+
2 error strategic/no-unmapped-context: ledger consumes payments, which the
|
|
51
|
+
context map does not allow: reverse the dependency, or if ledger
|
|
52
|
+
really is downstream of payments, add payments to
|
|
53
|
+
contextMap.ledger.consumes.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A map that would allow it is refused before the check:
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
Invalid configuration in alveolus.config.ts:
|
|
60
|
+
contextMap has a cycle: ledger → payments → ledger. Two contexts that depend
|
|
61
|
+
on each other can no longer change alone: reverse one dependency.
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Fix it
|
|
65
|
+
|
|
66
|
+
### Declare the context map
|
|
67
|
+
|
|
68
|
+
So that the direction of every dependency is a decision, not an accident, list for each context
|
|
69
|
+
the ones it consumes. Each line reads as a sentence: `payments` consumes `ledger` and `customers`.
|
|
70
|
+
|
|
71
|
+
```ts [alveolus.config.ts]
|
|
72
|
+
export default defineConfig({
|
|
73
|
+
boundedContexts: { customers: "customers", ledger: "ledger", payments: "payments" },
|
|
74
|
+
contextMap: {
|
|
75
|
+
customers: { consumes: [] },
|
|
76
|
+
ledger: { consumes: ["customers"] },
|
|
77
|
+
payments: { consumes: ["ledger", "customers"] },
|
|
78
|
+
},
|
|
79
|
+
root: "src",
|
|
80
|
+
subdomains: { core: ["ledger", "payments"], generic: ["customers"] },
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Every context is in the map. `consumes: []` is a decision too: that context goes its separate
|
|
85
|
+
way, and the day it needs another one, the import is reported and the map is updated on purpose.
|
|
86
|
+
|
|
87
|
+
### Reverse a dependency with an event
|
|
88
|
+
|
|
89
|
+
So that `Ledger` stays upstream, it does not ask `Payments` anything: it publishes
|
|
90
|
+
`TransferSettled` in its [published language](../../core/strategic/published-language.md), and
|
|
91
|
+
`Payments` reacts to it.
|
|
92
|
+
|
|
93
|
+
## Limits
|
|
94
|
+
|
|
95
|
+
::: warning What the rule cannot see
|
|
96
|
+
- 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 another context
|
|
98
|
+
is an import of its open host service.
|
|
99
|
+
:::
|
|
100
|
+
|
|
101
|
+
## Turn it off
|
|
102
|
+
|
|
103
|
+
```ts [alveolus.config.ts]
|
|
104
|
+
rules: { "strategic/no-unmapped-context": "off" },
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## See also
|
|
108
|
+
|
|
109
|
+
- [Open host services](../../core/strategic/open-host-services.md) and
|
|
110
|
+
[Anti-corruption layers](../../core/strategic/anti-corruption-layers.md), how a context consumes another
|
|
111
|
+
- [`strategic/no-cross-context-import`](./no-cross-context-import.md), which keeps the open host
|
|
112
|
+
service the only door
|
|
113
|
+
- Vaughn Vernon, *Domain-Driven Design Distilled*, chapter 4, "Strategic Design with Context Mapping"
|
|
114
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: an aggregate refers to another aggregate by its identifier, never by holding it, to keep transactions and loading small."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-aggregate-reference
|
|
6
|
+
|
|
7
|
+
An aggregate refers to another aggregate by its identifier, never by holding it.
|
|
8
|
+
|
|
9
|
+
<dl class="al-glance">
|
|
10
|
+
<dt>Rule</dt><dd><code>tactical/no-aggregate-reference</code></dd>
|
|
11
|
+
<dt>Category</dt><dd><a href="/rules/#tactical">Tactical</a>: how building blocks are written</dd>
|
|
12
|
+
<dt>Reports</dt><dd>A building block holding another aggregate, an entity held by two aggregates</dd>
|
|
13
|
+
<dt>Applies to</dt><dd>Every class that extends <code>AggregateRoot</code>, <code>Entity</code>, <code>ValueObject</code> or <code>DomainEvent</code>, in core bounded contexts and the shared kernel</dd>
|
|
14
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-aggregate-reference": "off"</code></a></dd>
|
|
15
|
+
</dl>
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
`Order` holds a `Customer`. The next handler that places an order also updates the customer, in
|
|
20
|
+
the same transaction, because it is right there. Loading an order now drags the customer along,
|
|
21
|
+
two users editing either one conflict, and the two boundaries have merged without anyone deciding
|
|
22
|
+
it.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The order keeps a `CustomerId`. Each aggregate stays a consistency boundary of its own, loaded,
|
|
26
|
+
changed and saved alone. The command handler loads the customer when it really needs it, and
|
|
27
|
+
changes to it happen in their own transaction, usually in reaction to an event.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
### No aggregate held
|
|
33
|
+
|
|
34
|
+
In every aggregate, entity, value object and domain event, no property, no constructor parameter,
|
|
35
|
+
no value object props and no event payload holds another aggregate, however deep it is:
|
|
36
|
+
|
|
37
|
+
<div class="al-cards">
|
|
38
|
+
<div class="al-card"><span class="al-card-title">Alone or in a union</span><code>customer: Customer | undefined</code></div>
|
|
39
|
+
<div class="al-card"><span class="al-card-title">In a generic</span>An array, a tuple, a <code>Map</code>, a <code>Record</code>, a <code>Pick</code>, a <code>Promise</code>, or a generic of your own.</div>
|
|
40
|
+
<div class="al-card"><span class="al-card-title">In an object type</span><code>{ customer: Customer }</code>, or an interface of the project.</div>
|
|
41
|
+
<div class="al-card"><span class="al-card-title">Loaded lazily</span><code>load: () => Promise<Customer></code>: what a function returns counts.</div>
|
|
42
|
+
<div class="al-card"><span class="al-card-title">In value object props</span><code>ValueObject<{ customer: Customer }></code></div>
|
|
43
|
+
<div class="al-card"><span class="al-card-title">In an event payload</span><code>DomainEvent<OrderId, { customer: Customer }></code></div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
The parameters of a function do not count: `onChange: (customer: Customer) => void` receives a
|
|
47
|
+
customer, it does not hold one.
|
|
48
|
+
|
|
49
|
+
### One owner per entity
|
|
50
|
+
|
|
51
|
+
An entity other than a root belongs to one aggregate. The rule finds every entity each aggregate
|
|
52
|
+
holds, directly or through its entities and value objects, and reports an entity held by two
|
|
53
|
+
aggregates: the `Address` of a `Customer` cannot be held by an `Order` too.
|
|
54
|
+
|
|
55
|
+
## What it reports
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
src/ordering/domain/aggregates/order.aggregate.ts
|
|
59
|
+
6 error tactical/no-aggregate-reference: Order.customer holds the
|
|
60
|
+
aggregate Customer: reference it by its identifier instead.
|
|
61
|
+
|
|
62
|
+
src/ordering/domain/value-objects/buyer.value-object.ts
|
|
63
|
+
3 error tactical/no-aggregate-reference: Buyer holds the aggregate
|
|
64
|
+
Customer in its Props: reference it by its identifier instead.
|
|
65
|
+
|
|
66
|
+
src/ordering/domain/aggregates/order.aggregate.ts
|
|
67
|
+
8 error tactical/no-aggregate-reference: Order.shipping holds the entity
|
|
68
|
+
Address, which Customer holds too: an entity belongs to one
|
|
69
|
+
aggregate.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Fix it
|
|
73
|
+
|
|
74
|
+
### Hold the identifier instead
|
|
75
|
+
|
|
76
|
+
So that each aggregate stays its own boundary, replace the property with the
|
|
77
|
+
[identifier](../../core/domain/value-objects.md#identifier) of the other aggregate, and put that
|
|
78
|
+
identifier in the snapshot.
|
|
79
|
+
|
|
80
|
+
<div class="al-compare">
|
|
81
|
+
|
|
82
|
+
```ts [❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
83
|
+
import { AggregateRoot } from "@alveolus/core";
|
|
84
|
+
|
|
85
|
+
import type { Customer } from "./customer.aggregate";
|
|
86
|
+
|
|
87
|
+
export class Order extends AggregateRoot<OrderId> {
|
|
88
|
+
private readonly customer: Customer;
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
```ts [✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
93
|
+
import { AggregateRoot } from "@alveolus/core";
|
|
94
|
+
|
|
95
|
+
import type { CustomerId } from "../value-objects/customer-id.identifier";
|
|
96
|
+
|
|
97
|
+
export class Order extends AggregateRoot<OrderId> {
|
|
98
|
+
private readonly customerId: CustomerId;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
### Give each aggregate its own entity
|
|
105
|
+
|
|
106
|
+
When two aggregates need the same kind of data, each one owns its own: the customer keeps its
|
|
107
|
+
`Address` entity, the order keeps a `ShippingAddress` value object copied from it when the order
|
|
108
|
+
is placed. Changing the customer's address no longer changes past orders.
|
|
109
|
+
|
|
110
|
+
### Load the other aggregate in the handler
|
|
111
|
+
|
|
112
|
+
When a rule needs data from the other aggregate, the
|
|
113
|
+
[command handler](../../core/application/command-handlers.md) loads it through its repository and
|
|
114
|
+
passes what the rule needs to the business method. When the other aggregate must change too, a
|
|
115
|
+
second handler does it on the event, in its own transaction.
|
|
116
|
+
|
|
117
|
+
## Limits
|
|
118
|
+
|
|
119
|
+
::: warning What the rule cannot see
|
|
120
|
+
- An interface with the shape of another aggregate, such as `CustomerLike`: TypeScript types are
|
|
121
|
+
structural, so the rule cannot tell that a `Customer` will be passed in. Hold the identifier.
|
|
122
|
+
- The parameters of a callback are not followed: `onChange: (customer: Customer) => void` is
|
|
123
|
+
accepted, and a closure can still capture the aggregate it receives.
|
|
124
|
+
:::
|
|
125
|
+
|
|
126
|
+
## Turn it off
|
|
127
|
+
|
|
128
|
+
```ts [alveolus.config.ts]
|
|
129
|
+
rules: { "tactical/no-aggregate-reference": "off" },
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
133
|
+
new code keeps the rule while you rework the old references.
|
|
134
|
+
|
|
135
|
+
## See also
|
|
136
|
+
|
|
137
|
+
- [Aggregates: refer to other aggregates by identity](../../core/domain/aggregates.md)
|
|
138
|
+
- [`tactical/no-thrown-failure`](./no-thrown-failure.md), another rule on aggregates
|
|
139
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule for command handlers: a command handler receives command repositories, ports, event translators, domain services and value objects, never a view or another handler."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-foreign-command-dependency
|
|
6
|
+
|
|
7
|
+
A command handler receives what changes aggregates and what they need: nothing that reads views,
|
|
8
|
+
and no other handler.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>tactical/no-foreign-command-dependency</code></dd>
|
|
12
|
+
<dt>Category</dt><dd><a href="/rules/#tactical">Tactical</a>: how building blocks are written</dd>
|
|
13
|
+
<dt>Reports</dt><dd>A command handler that receives a query repository, another handler or a class that is no building block</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>The constructor parameters and fields of every <code>CommandHandler</code> of a core bounded context or the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-foreign-command-dependency": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`PlaceOrderHandler` checks the order summary to decide whether the order may be placed. The view is
|
|
21
|
+
shaped for reading: it may be denormalised, stale or partial, and it protects no rule. Injecting
|
|
22
|
+
`GetOrderSummaryHandler` instead changes nothing, and injecting `PayOrderHandler` hides a second
|
|
23
|
+
transaction inside the first one.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
A command loads the aggregate and lets it decide. What a handler may receive is a short list:
|
|
27
|
+
everything else is reported, so a new way of reaching a view does not slip through.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Every constructor parameter and every field of each `CommandHandler`, followed into `Pick`,
|
|
33
|
+
generics and objects such as `deps: { … }`:
|
|
34
|
+
|
|
35
|
+
| Receives | Allowed |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| A `CommandRepository` | ✅ |
|
|
38
|
+
| A `Port`: `Clock`, `IdGenerator`, `UnitOfWork`, `Outbox`, your own ports | ✅ |
|
|
39
|
+
| An `EventTranslator`, a `DomainService` | ✅ |
|
|
40
|
+
| A value object, an identifier, a plain value such as a `number` | ✅ |
|
|
41
|
+
| A class of a package listed in `applicationDependencies` | ✅ |
|
|
42
|
+
| A `QueryRepository`, even though it is a `Port` | ❌ |
|
|
43
|
+
| An `EventPublisher`, even though it is a `Port`: events leave through the outbox, and the relay publishes them | ❌ |
|
|
44
|
+
| A `CommandHandler` or a `QueryHandler` | ❌ |
|
|
45
|
+
| Any other class of the project | ❌ |
|
|
46
|
+
|
|
47
|
+
A parameter counts by what its type extends: `OrderSummaries` extends
|
|
48
|
+
`QueryRepository<OrderSummary>`.
|
|
49
|
+
|
|
50
|
+
## What it reports
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
src/ordering/application/commands/place-order.command.ts
|
|
54
|
+
2 error tactical/no-foreign-command-dependency: The CommandHandler
|
|
55
|
+
PlaceOrderHandler receives OrderSummaries, a QueryRepository: a
|
|
56
|
+
command handler receives command repositories, ports, event
|
|
57
|
+
translators, domain services and value objects.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Fix it
|
|
61
|
+
|
|
62
|
+
### Decide from the aggregate
|
|
63
|
+
|
|
64
|
+
So that the decision is taken by what keeps the rules, a command handler loads the aggregate
|
|
65
|
+
through its [command repository](../../core/domain/repositories.md) and lets it decide.
|
|
66
|
+
|
|
67
|
+
<div class="al-compare">
|
|
68
|
+
|
|
69
|
+
```ts [❌ Avoid: src/ordering/application/commands/place-order.command.ts]
|
|
70
|
+
export class PlaceOrderHandler extends CommandHandler<PlaceOrder> {
|
|
71
|
+
constructor(private readonly summaries: OrderSummaries) {
|
|
72
|
+
super();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ts [✅ Prefer: src/ordering/application/commands/place-order.command.ts]
|
|
78
|
+
export class PlaceOrderHandler extends CommandHandler<PlaceOrder> {
|
|
79
|
+
constructor(private readonly orders: Orders) {
|
|
80
|
+
super();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
### React to an event instead of calling another handler
|
|
88
|
+
|
|
89
|
+
So that each command stays one transaction, a handler never calls another one. When placing an
|
|
90
|
+
order must also do something else, the other handler reacts to the domain event, in its own
|
|
91
|
+
transaction.
|
|
92
|
+
|
|
93
|
+
## Limits
|
|
94
|
+
|
|
95
|
+
::: warning What the rule cannot see
|
|
96
|
+
- A plain `Port` whose adapter reads the views: the rule sees a port, not what its adapter does.
|
|
97
|
+
In review, a port named like a read (`OrderStats`, `…Summary`) used by a command is a query in
|
|
98
|
+
disguise.
|
|
99
|
+
- An interface that a query repository happens to satisfy: an interface is no class, so the rule
|
|
100
|
+
cannot tell what will be injected. Type dependencies with the port class itself.
|
|
101
|
+
:::
|
|
102
|
+
|
|
103
|
+
## Turn it off
|
|
104
|
+
|
|
105
|
+
```ts [alveolus.config.ts]
|
|
106
|
+
rules: { "tactical/no-foreign-command-dependency": "off" },
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
110
|
+
new commands keep to their list while you rework the old ones.
|
|
111
|
+
|
|
112
|
+
## See also
|
|
113
|
+
|
|
114
|
+
- [Command handlers](../../core/application/command-handlers.md), what is checked
|
|
115
|
+
- [Repositories](../../core/domain/repositories.md) and [Ports](../../core/domain/ports.md), what a
|
|
116
|
+
command handler receives
|
|
117
|
+
- [`tactical/no-foreign-query-dependency`](./no-foreign-query-dependency.md), the same list for
|
|
118
|
+
query handlers
|
|
119
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule for query handlers: a query handler receives query repositories, ports that do not write and value objects, never what changes state."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-foreign-query-dependency
|
|
6
|
+
|
|
7
|
+
A query handler receives what reads: nothing that writes or changes state.
|
|
8
|
+
|
|
9
|
+
<dl class="al-glance">
|
|
10
|
+
<dt>Rule</dt><dd><code>tactical/no-foreign-query-dependency</code></dd>
|
|
11
|
+
<dt>Category</dt><dd><a href="/rules/#tactical">Tactical</a>: how building blocks are written</dd>
|
|
12
|
+
<dt>Reports</dt><dd>A query handler that receives a command repository, an outbox, a unit of work, an event publisher, a handler, a domain service, an event translator or a class that is no building block</dd>
|
|
13
|
+
<dt>Applies to</dt><dd>The constructor parameters and fields of every <code>QueryHandler</code> of a core bounded context or the shared kernel</dd>
|
|
14
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-foreign-query-dependency": "off"</code></a></dd>
|
|
15
|
+
</dl>
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
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 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.
|
|
29
|
+
|
|
30
|
+
::: tip The fix
|
|
31
|
+
A query reads a view through a query repository, and writes nothing. A query that seems to need a
|
|
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.
|
|
34
|
+
:::
|
|
35
|
+
|
|
36
|
+
## What it checks
|
|
37
|
+
|
|
38
|
+
Every constructor parameter and every field of each `QueryHandler`, followed into `Pick`,
|
|
39
|
+
generics and objects such as `deps: { … }`:
|
|
40
|
+
|
|
41
|
+
| Receives | Allowed |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| A `QueryRepository` | ✅ |
|
|
44
|
+
| A `Port` that does not write, such as `Clock` or your own ports | ✅ |
|
|
45
|
+
| A value object, an identifier, a plain value such as a `number` | ✅ |
|
|
46
|
+
| A class of a package listed in `applicationDependencies` | ✅ |
|
|
47
|
+
| A `CommandRepository`, an `Outbox`, a `UnitOfWork`, an `EventPublisher` | ❌ |
|
|
48
|
+
| A `CommandHandler`, a `QueryHandler`, a `DomainService`, an `EventTranslator` | ❌ |
|
|
49
|
+
| Any other class of the project | ❌ |
|
|
50
|
+
|
|
51
|
+
A parameter counts by what its type extends: `Orders` extends `CommandRepository<Order>`.
|
|
52
|
+
|
|
53
|
+
## What it reports
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
src/ordering/application/queries/get-order-summary.query.ts
|
|
57
|
+
3 error tactical/no-foreign-query-dependency: The QueryHandler
|
|
58
|
+
GetOrderSummaryHandler receives UnitOfWork, a UnitOfWork: a query
|
|
59
|
+
handler receives query repositories, ports that do not write, and
|
|
60
|
+
value objects.
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Fix it
|
|
64
|
+
|
|
65
|
+
### Read a view, and nothing else
|
|
66
|
+
|
|
67
|
+
So that a read never changes state, a [query handler](../../core/application/query-handlers.md)
|
|
68
|
+
receives only query repositories.
|
|
69
|
+
|
|
70
|
+
<div class="al-compare">
|
|
71
|
+
|
|
72
|
+
```ts [❌ Avoid: src/ordering/application/queries/get-order-summary.query.ts]
|
|
73
|
+
constructor(
|
|
74
|
+
private readonly summaries: OrderSummaries,
|
|
75
|
+
private readonly unitOfWork: UnitOfWork,
|
|
76
|
+
) {
|
|
77
|
+
super();
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```ts [✅ Prefer: src/ordering/application/queries/get-order-summary.query.ts]
|
|
82
|
+
constructor(private readonly summaries: OrderSummaries) {
|
|
83
|
+
super();
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
</div>
|
|
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
|
+
|
|
174
|
+
## Limits
|
|
175
|
+
|
|
176
|
+
::: warning What the rule cannot see
|
|
177
|
+
- A plain `Port` whose adapter writes: the rule sees a port, not what its adapter does. In review,
|
|
178
|
+
a port with a verb such as `mark…`, `record…` or `save…` has no place in a query.
|
|
179
|
+
- An interface that a command repository happens to satisfy: an interface is no class, so the rule
|
|
180
|
+
cannot tell what will be injected. Type dependencies with the port class itself.
|
|
181
|
+
:::
|
|
182
|
+
|
|
183
|
+
## Turn it off
|
|
184
|
+
|
|
185
|
+
```ts [alveolus.config.ts]
|
|
186
|
+
rules: { "tactical/no-foreign-query-dependency": "off" },
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
190
|
+
new queries keep to reading while you split the old ones.
|
|
191
|
+
|
|
192
|
+
## See also
|
|
193
|
+
|
|
194
|
+
- [Query handlers](../../core/application/query-handlers.md), what is checked
|
|
195
|
+
- [Repositories](../../core/domain/repositories.md) and [Views](../../core/domain/views.md), what
|
|
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
|
|
199
|
+
- [`tactical/no-foreign-command-dependency`](./no-foreign-command-dependency.md), the same list for
|
|
200
|
+
command handlers
|
|
201
|
+
- [Rules](../index.md), every rule by category
|