@alveolus/arch 0.2.0 → 0.3.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 +6 -0
- package/dist/bin.mjs +4 -2
- package/dist/bin.mjs.map +1 -1
- package/dist/{cli-P5PwH9OE.mjs → docs-DsQHpTtV.mjs} +190 -5
- package/dist/docs-DsQHpTtV.mjs.map +1 -0
- package/dist/index.d.mts +44 -2
- 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 +105 -0
- package/docs/guide/getting-started.md +275 -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 +183 -0
- package/docs/rules/layers/no-driving-shortcut.md +119 -0
- package/docs/rules/layers/no-impure-domain.md +189 -0
- package/docs/rules/layers/no-outward-import.md +184 -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 +111 -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 +106 -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,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,106 @@
|
|
|
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 or a domain service injected into it
|
|
21
|
+
does the same, one step removed.
|
|
22
|
+
|
|
23
|
+
::: tip The fix
|
|
24
|
+
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.
|
|
26
|
+
:::
|
|
27
|
+
|
|
28
|
+
## What it checks
|
|
29
|
+
|
|
30
|
+
Every constructor parameter and every field of each `QueryHandler`, followed into `Pick`,
|
|
31
|
+
generics and objects such as `deps: { … }`:
|
|
32
|
+
|
|
33
|
+
| Receives | Allowed |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| A `QueryRepository` | ✅ |
|
|
36
|
+
| A `Port` that does not write, such as `Clock` or your own ports | ✅ |
|
|
37
|
+
| A value object, an identifier, a plain value such as a `number` | ✅ |
|
|
38
|
+
| A class of a package listed in `applicationDependencies` | ✅ |
|
|
39
|
+
| A `CommandRepository`, an `Outbox`, a `UnitOfWork`, an `EventPublisher` | ❌ |
|
|
40
|
+
| A `CommandHandler`, a `QueryHandler`, a `DomainService`, an `EventTranslator` | ❌ |
|
|
41
|
+
| Any other class of the project | ❌ |
|
|
42
|
+
|
|
43
|
+
A parameter counts by what its type extends: `Orders` extends `CommandRepository<Order>`.
|
|
44
|
+
|
|
45
|
+
## What it reports
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
src/ordering/application/queries/get-order-summary.query.ts
|
|
49
|
+
3 error tactical/no-foreign-query-dependency: The QueryHandler
|
|
50
|
+
GetOrderSummaryHandler receives UnitOfWork, a UnitOfWork: a query
|
|
51
|
+
handler receives query repositories, ports that do not write, and
|
|
52
|
+
value objects.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Fix it
|
|
56
|
+
|
|
57
|
+
### Read a view, and nothing else
|
|
58
|
+
|
|
59
|
+
So that a read never changes state, a [query handler](../../core/application/query-handlers.md)
|
|
60
|
+
receives only query repositories.
|
|
61
|
+
|
|
62
|
+
<div class="al-compare">
|
|
63
|
+
|
|
64
|
+
```ts [❌ Avoid: src/ordering/application/queries/get-order-summary.query.ts]
|
|
65
|
+
constructor(
|
|
66
|
+
private readonly summaries: OrderSummaries,
|
|
67
|
+
private readonly unitOfWork: UnitOfWork,
|
|
68
|
+
) {
|
|
69
|
+
super();
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```ts [✅ Prefer: src/ordering/application/queries/get-order-summary.query.ts]
|
|
74
|
+
constructor(private readonly summaries: OrderSummaries) {
|
|
75
|
+
super();
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
## Limits
|
|
82
|
+
|
|
83
|
+
::: warning What the rule cannot see
|
|
84
|
+
- A plain `Port` whose adapter writes: the rule sees a port, not what its adapter does. In review,
|
|
85
|
+
a port with a verb such as `mark…`, `record…` or `save…` has no place in a query.
|
|
86
|
+
- An interface that a command repository happens to satisfy: an interface is no class, so the rule
|
|
87
|
+
cannot tell what will be injected. Type dependencies with the port class itself.
|
|
88
|
+
:::
|
|
89
|
+
|
|
90
|
+
## Turn it off
|
|
91
|
+
|
|
92
|
+
```ts [alveolus.config.ts]
|
|
93
|
+
rules: { "tactical/no-foreign-query-dependency": "off" },
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
97
|
+
new queries keep to reading while you split the old ones.
|
|
98
|
+
|
|
99
|
+
## See also
|
|
100
|
+
|
|
101
|
+
- [Query handlers](../../core/application/query-handlers.md), what is checked
|
|
102
|
+
- [Repositories](../../core/domain/repositories.md) and [Views](../../core/domain/views.md), what
|
|
103
|
+
a query reads
|
|
104
|
+
- [`tactical/no-foreign-command-dependency`](./no-foreign-command-dependency.md), the same list for
|
|
105
|
+
command handlers
|
|
106
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: the domain and application layers contain only building blocks, types and constants of data, with no loose function, namespace or module state."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-loose-code
|
|
6
|
+
|
|
7
|
+
The domain and the application contain building blocks, types and constants of data, and nothing
|
|
8
|
+
else: every class extends a building block of `@alveolus/core`, and every function is a method.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>tactical/no-loose-code</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 plain class, a class with only static members or that extends an expression, a function, an enum, a namespace, a computed constant, module state, a statement run on load; anything but the module class in a composition root</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Files in <code>domain/</code> and <code>application/</code>, in every core bounded context and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-loose-code": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
Rounding an amount needs a few lines, so a `roundAmount` function lands in `pricing.ts`. The next
|
|
21
|
+
helper goes next to it, then a `utils.ts` appears, and the model slowly dissolves into code that has
|
|
22
|
+
no defined place and that no other rule knows how to treat.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
Every element of the domain and the application is a building block. Rounding belongs to `Money`,
|
|
26
|
+
a value object; a rule that no object owns goes in a `DomainService`. Each one has a home that
|
|
27
|
+
people and agents can find, and the rest of the rules know what it is.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Every top-level statement of a file in `domain/` or `application/`. The list says what is allowed;
|
|
33
|
+
anything else is reported:
|
|
34
|
+
|
|
35
|
+
| Statement | Allowed when |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| An `import` or an `export` | Always. |
|
|
38
|
+
| A `type` or an `interface`, a `declare global` block | Always. |
|
|
39
|
+
| A class | It extends a building block of `@alveolus/core` by its name, directly or through your own base class, and has at least one member that is not static. A mixin, a cast or a constant in `extends` hides what the class is. |
|
|
40
|
+
| A `const` | Its value is plain data: a literal, an array or object of data, arithmetic or a template on data, a reference to another constant, with `as const` or `satisfies` if you like. |
|
|
41
|
+
| A function, or a constant holding one, even wrapped in a call, parentheses or `as` | Never. |
|
|
42
|
+
| An object holding a method, a value computed by a call or `new`, a class expression | Never. |
|
|
43
|
+
| `let` or `var`: state kept by the module | Never. |
|
|
44
|
+
| An `enum`, a `namespace` | Never. |
|
|
45
|
+
| A statement run when the module loads, such as `registry.register(Order)` | Never. |
|
|
46
|
+
|
|
47
|
+
The building blocks to extend are, in the domain, `AggregateRoot`, `Entity`, `ValueObject`,
|
|
48
|
+
`Identifier`, `DomainEvent`, `DomainError`, `DomainService`, a `Port` or a repository; in the
|
|
49
|
+
application, `CommandHandler`, `QueryHandler` or `EventTranslator`.
|
|
50
|
+
|
|
51
|
+
<div class="al-cards">
|
|
52
|
+
<div class="al-card"><span class="al-card-title">Allowed</span>Types, interfaces and constants holding data, and functions written inside a method. A static factory next to instance members.</div>
|
|
53
|
+
<div class="al-card"><span class="al-card-title">Composition roots</span>Its module class, imports and constants of data: a function, a computed constant or a statement around it is reported.</div>
|
|
54
|
+
<div class="al-card"><span class="al-card-title">Adapter layers</span><code>driven/</code> and <code>driving/</code> hold classes: adapters, mappers, controllers, any class. A function, a computed constant or module state is reported there too.</div>
|
|
55
|
+
<div class="al-card"><span class="al-card-title">Not checked</span>The files at the root of <code>src/</code>, such as <code>main.ts</code>.</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
## What it reports
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
src/ordering/domain/services/pricing.ts
|
|
62
|
+
1 error tactical/no-loose-code: PriceHelper extends no building
|
|
63
|
+
block: extend AggregateRoot, Entity, ValueObject, Identifier,
|
|
64
|
+
DomainEvent, DomainError, DomainService or a Port.
|
|
65
|
+
3 error tactical/no-loose-code: The function roundAmount floats
|
|
66
|
+
outside any class: make it a method of a value object or of
|
|
67
|
+
a DomainService.
|
|
68
|
+
7 error tactical/no-loose-code: The enum OrderStatus has no place
|
|
69
|
+
here: use a union of literal types, or a ValueObject when it
|
|
70
|
+
has behaviour.
|
|
71
|
+
12 error tactical/no-loose-code: The constant Pricing is computed when
|
|
72
|
+
the module loads: keep top-level constants to plain data.
|
|
73
|
+
|
|
74
|
+
src/ordering/domain/value-objects/utils.value-object.ts
|
|
75
|
+
3 error tactical/no-loose-code: Utils only has static members: a class
|
|
76
|
+
of functions is no building block; make them methods of the
|
|
77
|
+
value object they work on, or of a DomainService.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
In the application, the message lists `CommandHandler, QueryHandler or EventTranslator`.
|
|
81
|
+
|
|
82
|
+
## Fix it
|
|
83
|
+
|
|
84
|
+
### Find the building block it belongs to
|
|
85
|
+
|
|
86
|
+
So that each piece of logic has a known home, replace each declaration with the building block that
|
|
87
|
+
owns it:
|
|
88
|
+
|
|
89
|
+
| Instead of | Write |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| A helper function, an object or a namespace of functions, a class of static helpers | A method of the value object it works on, or of a `DomainService` when no object owns it. |
|
|
92
|
+
| A plain class (policy, calculator, specification) | A `DomainService`. |
|
|
93
|
+
| A factory function | A static method of the aggregate, entity or value object. |
|
|
94
|
+
| A mapper in the application | An `EventTranslator`, or a mapper in an adapter. |
|
|
95
|
+
| An `enum` | A union of literal types, or a value object when the values have behaviour. |
|
|
96
|
+
| A constant built by `new Currency("EUR")` | A static getter of the value object: `Currency.euro`. |
|
|
97
|
+
| A counter or a cache in a module | State of an aggregate, or a port implemented by an adapter. |
|
|
98
|
+
|
|
99
|
+
<div class="al-compare">
|
|
100
|
+
|
|
101
|
+
```ts [❌ Avoid: src/ordering/domain/services/pricing.ts]
|
|
102
|
+
export class PriceHelper {}
|
|
103
|
+
|
|
104
|
+
export function roundAmount(amount: number): number {
|
|
105
|
+
return Math.round(amount * 100) / 100;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export enum OrderStatus {
|
|
109
|
+
Draft,
|
|
110
|
+
Placed,
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
```ts [✅ Prefer: src/ordering/domain/value-objects/money.value-object.ts]
|
|
115
|
+
import { ValueObject } from "@alveolus/core";
|
|
116
|
+
|
|
117
|
+
export class Money extends ValueObject<{ readonly amount: number }> {
|
|
118
|
+
rounded(): Money {
|
|
119
|
+
return new Money({
|
|
120
|
+
amount: Math.round(this.props.amount * 100) / 100,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
</div>
|
|
127
|
+
|
|
128
|
+
### Return business failures as domain errors
|
|
129
|
+
|
|
130
|
+
So that callers see in the signature what can go wrong, a business failure is not a subclass of
|
|
131
|
+
`Error`: it is a `DomainError`, returned in a [`Result`](../../core/utilities/result.md). A
|
|
132
|
+
technical failure is thrown by an adapter, never by the domain.
|
|
133
|
+
|
|
134
|
+
<div class="al-compare">
|
|
135
|
+
|
|
136
|
+
```ts [❌ Avoid: src/ordering/domain/errors/price-too-high.error.ts]
|
|
137
|
+
export class PriceTooHigh extends Error {}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
```ts [✅ Prefer: src/ordering/domain/errors/price-too-high.error.ts]
|
|
141
|
+
import { DomainError } from "@alveolus/core";
|
|
142
|
+
|
|
143
|
+
export class PriceTooHigh extends DomainError<{ readonly max: number }> {}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
## Limits
|
|
149
|
+
|
|
150
|
+
::: warning What the rule cannot see
|
|
151
|
+
- The methods of the module class in a composition root are not read: a business rule written in
|
|
152
|
+
`OrderingModule.discount()` goes unnoticed. The module only wires.
|
|
153
|
+
- A constant may refer to a class, such as `[PlaceOrderHandler, GetOrderSummaryHandler]`: classes
|
|
154
|
+
are values that cannot be called, so they count as data.
|
|
155
|
+
:::
|
|
156
|
+
|
|
157
|
+
## Turn it off
|
|
158
|
+
|
|
159
|
+
```ts [alveolus.config.ts]
|
|
160
|
+
rules: { "tactical/no-loose-code": "off" },
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
164
|
+
new code keeps to building blocks while you move the old helpers.
|
|
165
|
+
|
|
166
|
+
## See also
|
|
167
|
+
|
|
168
|
+
- [Building blocks](../../core/index.md), what each class can extend
|
|
169
|
+
- [`tactical/no-misplaced-class`](./no-misplaced-class.md), for the folder of each building block
|
|
170
|
+
- [`tactical/no-thrown-failure`](./no-thrown-failure.md), for business failures
|
|
171
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: each class lives in the folder of its kind, in a file named after that kind, one class per file."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-misplaced-class
|
|
6
|
+
|
|
7
|
+
Each class lives in the folder of its kind, in a file whose name ends with that kind, one class per
|
|
8
|
+
file.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>tactical/no-misplaced-class</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 class in the wrong folder or file, two classes in one file</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every class that extends a building block, in every core bounded context and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-misplaced-class": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`OrderId` and `Order` are declared together in `domain/aggregates/order.ts`, because that is where the task
|
|
21
|
+
started. The next developer looks for the identifier in `value-objects/` and does not find it; the
|
|
22
|
+
next agent creates a second one there. The shared layout only helps if it holds.
|
|
23
|
+
|
|
24
|
+
::: tip The fix
|
|
25
|
+
The kind of a class decides where it lives: `Order` extends `AggregateRoot`, so it is in
|
|
26
|
+
`domain/aggregates/order.aggregate.ts`, alone. Finding a concept takes no search, a review shows at
|
|
27
|
+
a glance what a change touches, and an agent puts new code where the existing code is.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Two things, in every file:
|
|
33
|
+
|
|
34
|
+
<div class="al-cards al-cards-2">
|
|
35
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>One class per file</span>The types that belong to a class, such as its snapshot or its command input, stay in its file.</div>
|
|
36
|
+
<div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>The folder and file of its kind</span>A class that extends a building block is where the table below says, whatever its name.</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
| Extends | Folder | File name ends with |
|
|
40
|
+
| --- | --- | --- |
|
|
41
|
+
| `AggregateRoot` | `domain/aggregates/` | `.aggregate.ts` |
|
|
42
|
+
| `Entity` | `domain/entities/` | `.entity.ts` |
|
|
43
|
+
| `Identifier` | `domain/value-objects/` | `.identifier.ts` |
|
|
44
|
+
| `ValueObject` | `domain/value-objects/` | `.value-object.ts` |
|
|
45
|
+
| `DomainEvent` | `domain/events/` | `.event.ts` |
|
|
46
|
+
| `DomainError` | `domain/errors/` | `.error.ts` |
|
|
47
|
+
| `DomainService` | `domain/services/` | `.service.ts` |
|
|
48
|
+
| `CommandRepository`<br>`QueryRepository` | `domain/repositories/` | `.repository.ts` |
|
|
49
|
+
| `Port`, abstract | `domain/ports/` | `.port.ts` |
|
|
50
|
+
| `CommandHandler` | `application/commands/` | `.command.ts` |
|
|
51
|
+
| `QueryHandler` | `application/queries/` | `.query.ts` |
|
|
52
|
+
| `EventTranslator` | `application/translators/` | `.translator.ts` |
|
|
53
|
+
| a port, concrete | `driven/<technology>/adapters/` | `.adapter.ts` |
|
|
54
|
+
|
|
55
|
+
A marker fixes the place of its class, whatever the class extends: a class that implements
|
|
56
|
+
`AntiCorruptionLayer` is an adapter in `driven/<technology>/adapters/*.adapter.ts`, a class that
|
|
57
|
+
implements `OpenHostService` lives under `driving/<technology>/`, with any file name. A command
|
|
58
|
+
handler marked as an anti-corruption layer is reported. Classes that extend no building block, such as a controller
|
|
59
|
+
or a module, are not placed by this rule.
|
|
60
|
+
|
|
61
|
+
## What it reports
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
src/ordering/domain/aggregates/order.ts
|
|
65
|
+
3 error tactical/no-misplaced-class: OrderId belongs in
|
|
66
|
+
domain/value-objects/*.identifier.ts.
|
|
67
|
+
5 error tactical/no-misplaced-class: Order shares its file
|
|
68
|
+
with OrderId: one class per file.
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A class that shares its file is reported for that only: its place is checked once it has a file
|
|
72
|
+
of its own.
|
|
73
|
+
|
|
74
|
+
## Fix it
|
|
75
|
+
|
|
76
|
+
### Move each class to the file of its kind
|
|
77
|
+
|
|
78
|
+
Split the file, then move each class to the folder and file name the message gives. Import the
|
|
79
|
+
others from their new place.
|
|
80
|
+
|
|
81
|
+
<div class="al-compare">
|
|
82
|
+
|
|
83
|
+
```ts [❌ Avoid: src/ordering/domain/aggregates/order.ts]
|
|
84
|
+
import { AggregateRoot, Identifier } from "@alveolus/core";
|
|
85
|
+
|
|
86
|
+
export class OrderId extends Identifier<string, "OrderId"> {}
|
|
87
|
+
|
|
88
|
+
export class Order extends AggregateRoot<OrderId> {}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
```ts [✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts]
|
|
92
|
+
import { AggregateRoot } from "@alveolus/core";
|
|
93
|
+
|
|
94
|
+
import type { OrderId } from "../value-objects/order-id.identifier";
|
|
95
|
+
|
|
96
|
+
export class Order extends AggregateRoot<OrderId> {}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
### Keep the types of a class in its file
|
|
102
|
+
|
|
103
|
+
A snapshot, a command input or an error union is not a class: it stays next to the class it
|
|
104
|
+
belongs to, and the rule does not report it.
|
|
105
|
+
|
|
106
|
+
```ts [src/ordering/application/commands/place-order.command.ts]
|
|
107
|
+
export interface PlaceOrder {
|
|
108
|
+
readonly orderId: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export type PlaceOrderError =
|
|
112
|
+
| OrderNotFound
|
|
113
|
+
| OrderAlreadyPlaced
|
|
114
|
+
| EmptyOrder;
|
|
115
|
+
|
|
116
|
+
export class PlaceOrderHandler extends CommandHandler<
|
|
117
|
+
PlaceOrder,
|
|
118
|
+
void,
|
|
119
|
+
PlaceOrderError
|
|
120
|
+
> { … }
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Limits
|
|
124
|
+
|
|
125
|
+
::: warning What the rule cannot see
|
|
126
|
+
- A class that extends no building block: it has no place to be in, so the rule leaves it alone;
|
|
127
|
+
`tactical/no-loose-code` reports it in the domain and the application.
|
|
128
|
+
- The file name and the class name: `order.aggregate.ts` may declare `Invoice`. The suffix is
|
|
129
|
+
checked, the stem is not.
|
|
130
|
+
- Types and interfaces: a `View` in `domain/views/` is a type, and the rule places classes.
|
|
131
|
+
:::
|
|
132
|
+
|
|
133
|
+
## Turn it off
|
|
134
|
+
|
|
135
|
+
```ts [alveolus.config.ts]
|
|
136
|
+
rules: { "tactical/no-misplaced-class": "off" },
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
140
|
+
new code keeps the layout while you move the old one.
|
|
141
|
+
|
|
142
|
+
## See also
|
|
143
|
+
|
|
144
|
+
- [Project layout: folders and file names](../../guide/project-layout.md#folders-and-file-names)
|
|
145
|
+
- [`tactical/no-loose-code`](./no-loose-code.md), so that every class has a kind
|
|
146
|
+
- [Rules](../index.md), every rule by category
|