@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.
Files changed (60) hide show
  1. package/README.md +11 -0
  2. package/dist/bin.mjs +4 -2
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/{cli-P5PwH9OE.mjs → docs-DcFgskuN.mjs} +214 -43
  5. package/dist/docs-DcFgskuN.mjs.map +1 -0
  6. package/dist/index.d.mts +53 -12
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +2 -2
  9. package/docs/core/application/command-handlers.md +617 -0
  10. package/docs/core/application/event-publishers.md +234 -0
  11. package/docs/core/application/event-translators.md +329 -0
  12. package/docs/core/application/index.md +99 -0
  13. package/docs/core/application/integration-events.md +277 -0
  14. package/docs/core/application/outbox.md +416 -0
  15. package/docs/core/application/query-handlers.md +292 -0
  16. package/docs/core/application/unit-of-work.md +352 -0
  17. package/docs/core/domain/aggregates.md +822 -0
  18. package/docs/core/domain/domain-errors.md +251 -0
  19. package/docs/core/domain/domain-events.md +292 -0
  20. package/docs/core/domain/domain-services.md +249 -0
  21. package/docs/core/domain/entities.md +431 -0
  22. package/docs/core/domain/index.md +93 -0
  23. package/docs/core/domain/ports.md +284 -0
  24. package/docs/core/domain/repositories.md +335 -0
  25. package/docs/core/domain/value-objects.md +425 -0
  26. package/docs/core/domain/views.md +265 -0
  27. package/docs/core/index.md +108 -0
  28. package/docs/core/strategic/anti-corruption-layers.md +349 -0
  29. package/docs/core/strategic/index.md +83 -0
  30. package/docs/core/strategic/open-host-services.md +287 -0
  31. package/docs/core/strategic/published-language.md +265 -0
  32. package/docs/core/utilities/result.md +413 -0
  33. package/docs/guide/agents.md +68 -0
  34. package/docs/guide/existing-project.md +108 -0
  35. package/docs/guide/getting-started.md +286 -0
  36. package/docs/guide/learning-path.md +123 -0
  37. package/docs/guide/project-layout.md +324 -0
  38. package/docs/guide/versioning.md +42 -0
  39. package/docs/integrations/index.md +112 -0
  40. package/docs/integrations/nestjs.md +169 -0
  41. package/docs/rules/index.md +185 -0
  42. package/docs/rules/layers/no-driving-shortcut.md +119 -0
  43. package/docs/rules/layers/no-impure-domain.md +191 -0
  44. package/docs/rules/layers/no-outward-import.md +186 -0
  45. package/docs/rules/layers/no-portless-adapter.md +123 -0
  46. package/docs/rules/strategic/no-cross-context-import.md +140 -0
  47. package/docs/rules/strategic/no-fat-shared-kernel.md +81 -0
  48. package/docs/rules/strategic/no-leaky-host-service.md +107 -0
  49. package/docs/rules/strategic/no-unmapped-context.md +114 -0
  50. package/docs/rules/tactical/no-aggregate-reference.md +139 -0
  51. package/docs/rules/tactical/no-foreign-command-dependency.md +119 -0
  52. package/docs/rules/tactical/no-foreign-query-dependency.md +201 -0
  53. package/docs/rules/tactical/no-loose-code.md +171 -0
  54. package/docs/rules/tactical/no-misplaced-class.md +146 -0
  55. package/docs/rules/tactical/no-public-field.md +113 -0
  56. package/docs/rules/tactical/no-stateful-service.md +102 -0
  57. package/docs/rules/tactical/no-thrown-failure.md +162 -0
  58. package/docs/rules/tooling/no-loose-disable.md +98 -0
  59. package/package.json +4 -3
  60. 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: () =&gt; Promise&lt;Customer&gt;</code>: what a function returns counts.</div>
42
+ <div class="al-card"><span class="al-card-title">In value object props</span><code>ValueObject&lt;{ customer: Customer }&gt;</code></div>
43
+ <div class="al-card"><span class="al-card-title">In an event payload</span><code>DomainEvent&lt;OrderId, { customer: Customer }&gt;</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