@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,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
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule: an aggregate, an entity, a value object or an identifier keeps its state private and exposes it through getters."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-public-field
|
|
6
|
+
|
|
7
|
+
An aggregate, an entity, a value object or an identifier keeps its state private: nothing outside
|
|
8
|
+
changes it, and what callers need is read through a getter.
|
|
9
|
+
|
|
10
|
+
<dl class="al-glance">
|
|
11
|
+
<dt>Rule</dt><dd><code>tactical/no-public-field</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 public instance field, declared or as a constructor parameter, <code>readonly</code> or not</dd>
|
|
14
|
+
<dt>Applies to</dt><dd>Every class that extends <code>AggregateRoot</code>, <code>Entity</code>, <code>ValueObject</code> or <code>Identifier</code>, in core bounded contexts and the shared kernel</dd>
|
|
15
|
+
<dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-public-field": "off"</code></a></dd>
|
|
16
|
+
</dl>
|
|
17
|
+
|
|
18
|
+
## Why
|
|
19
|
+
|
|
20
|
+
`Account` has `public balance = 0`. The withdraw handler checks the balance and subtracts the
|
|
21
|
+
amount itself; the next handler does the same with a slightly different check. The rule "an
|
|
22
|
+
account never goes below its overdraft" now lives in four handlers, and the aggregate is a bag of
|
|
23
|
+
fields: the model is anemic, and the day the rule changes, nobody finds every copy.
|
|
24
|
+
|
|
25
|
+
::: tip The fix
|
|
26
|
+
The state is private, and changes through a method that returns a `Result`: `account.withdraw(amount)`.
|
|
27
|
+
A value the outside needs to read is a getter. The rule lives once, where the state is.
|
|
28
|
+
:::
|
|
29
|
+
|
|
30
|
+
## What it checks
|
|
31
|
+
|
|
32
|
+
Every instance field of a class that extends one of the four building blocks:
|
|
33
|
+
|
|
34
|
+
| Field | Allowed |
|
|
35
|
+
| --- | --- |
|
|
36
|
+
| `private balance`, `protected readonly opened` | ✅ |
|
|
37
|
+
| `constructor(private readonly currency: string)` | ✅ |
|
|
38
|
+
| `get balance(): Money` | ✅ |
|
|
39
|
+
| `public static readonly limit = 100` | ✅ |
|
|
40
|
+
| `public balance = 0`, `public readonly currency` | ❌ |
|
|
41
|
+
| `constructor(public readonly owner: string)` | ❌ |
|
|
42
|
+
|
|
43
|
+
A `readonly` public field is reported too: a value object of it can still be mutated, and the
|
|
44
|
+
getter keeps the shape of the class free to change.
|
|
45
|
+
|
|
46
|
+
## What it reports
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
src/ledger/domain/aggregates/account.aggregate.ts
|
|
50
|
+
4 error tactical/no-public-field: Account.balance is a public field:
|
|
51
|
+
keep the state private, and expose what callers need through a
|
|
52
|
+
getter.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Fix it
|
|
56
|
+
|
|
57
|
+
### Change the state through a method
|
|
58
|
+
|
|
59
|
+
<div class="al-compare">
|
|
60
|
+
|
|
61
|
+
```ts [❌ Avoid: src/ledger/domain/aggregates/account.aggregate.ts]
|
|
62
|
+
export class Account extends AggregateRoot<AccountId> {
|
|
63
|
+
public balance = 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// in a handler
|
|
67
|
+
if (account.balance >= amount) {
|
|
68
|
+
account.balance -= amount;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```ts [✅ Prefer: src/ledger/domain/aggregates/account.aggregate.ts]
|
|
73
|
+
export class Account extends AggregateRoot<AccountId> {
|
|
74
|
+
private balance = 0;
|
|
75
|
+
|
|
76
|
+
get currentBalance(): number {
|
|
77
|
+
return this.balance;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
withdraw(amount: number): Result<void, InsufficientFunds> {
|
|
81
|
+
if (this.balance < amount) {
|
|
82
|
+
return err(new InsufficientFunds({ amount }));
|
|
83
|
+
}
|
|
84
|
+
this.balance -= amount;
|
|
85
|
+
return ok();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
## Limits
|
|
93
|
+
|
|
94
|
+
::: warning What the rule cannot see
|
|
95
|
+
- A getter that returns a mutable object, such as the array of lines: a caller can push into it.
|
|
96
|
+
Return a copy, or a readonly type.
|
|
97
|
+
- A `protected` field: a subclass may change it. The rule stops the outside, not the hierarchy.
|
|
98
|
+
:::
|
|
99
|
+
|
|
100
|
+
## Turn it off
|
|
101
|
+
|
|
102
|
+
```ts [alveolus.config.ts]
|
|
103
|
+
rules: { "tactical/no-public-field": "off" },
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
107
|
+
new fields stay private while you move the rules back into the aggregates.
|
|
108
|
+
|
|
109
|
+
## See also
|
|
110
|
+
|
|
111
|
+
- [Aggregates](../../core/domain/aggregates.md) and [Entities](../../core/domain/entities.md), what is checked
|
|
112
|
+
- [`tactical/no-thrown-failure`](./no-thrown-failure.md), which makes every change return a `Result`
|
|
113
|
+
- [Rules](../index.md), every rule by category
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: "Architecture rule for domain services: a domain service is stateless and holds configuration only, never a port, a repository or another service."
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# no-stateful-service
|
|
6
|
+
|
|
7
|
+
A domain service holds configuration only: the command handler passes it what it needs.
|
|
8
|
+
|
|
9
|
+
<dl class="al-glance">
|
|
10
|
+
<dt>Rule</dt><dd><code>tactical/no-stateful-service</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 domain service that holds a port, a repository, another service or any class other than a value object</dd>
|
|
13
|
+
<dt>Applies to</dt><dd>The constructor parameters and fields of every <code>DomainService</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-stateful-service": "off"</code></a></dd>
|
|
15
|
+
</dl>
|
|
16
|
+
|
|
17
|
+
## Why
|
|
18
|
+
|
|
19
|
+
`OrderLimit` receives `Orders` to count the orders of a customer. The rule now loads data on its
|
|
20
|
+
own, a query handler that uses it can write, and testing it needs a repository. A domain service
|
|
21
|
+
is a stateless operation: it is given the aggregates and values it works on.
|
|
22
|
+
|
|
23
|
+
::: tip The fix
|
|
24
|
+
The command handler loads what the rule needs and passes it to the service method. The service
|
|
25
|
+
keeps only its configuration, such as a limit or a rate.
|
|
26
|
+
:::
|
|
27
|
+
|
|
28
|
+
## What it checks
|
|
29
|
+
|
|
30
|
+
Every constructor parameter and every field of each `DomainService`:
|
|
31
|
+
|
|
32
|
+
| Holds | Allowed |
|
|
33
|
+
| --- | --- |
|
|
34
|
+
| A plain value such as a `number` or a `string` | ✅ |
|
|
35
|
+
| A value object, an identifier | ✅ |
|
|
36
|
+
| A class of a package listed in `domainDependencies` | ✅ |
|
|
37
|
+
| A `Port`, a `CommandRepository`, a `QueryRepository` | ❌ |
|
|
38
|
+
| Another `DomainService`, an aggregate, an entity, a handler | ❌ |
|
|
39
|
+
| Any other class of the project | ❌ |
|
|
40
|
+
|
|
41
|
+
## What it reports
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
src/ordering/domain/services/order-limit.service.ts
|
|
45
|
+
4 error tactical/no-stateful-service: The DomainService OrderLimit holds
|
|
46
|
+
Orders, a CommandRepository: a domain service holds configuration
|
|
47
|
+
only; the command handler passes it what it needs.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Fix it
|
|
51
|
+
|
|
52
|
+
### Pass what the rule needs to the method
|
|
53
|
+
|
|
54
|
+
So that the service stays a pure operation, the command handler loads the data and passes it in.
|
|
55
|
+
|
|
56
|
+
<div class="al-compare">
|
|
57
|
+
|
|
58
|
+
```ts [❌ Avoid: src/ordering/domain/services/order-limit.service.ts]
|
|
59
|
+
export class OrderLimit extends DomainService {
|
|
60
|
+
constructor(private readonly orders: Orders) {
|
|
61
|
+
super();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```ts [✅ Prefer: src/ordering/domain/services/order-limit.service.ts]
|
|
67
|
+
export class OrderLimit extends DomainService {
|
|
68
|
+
constructor(private readonly maxLinesForNewCustomers: number) {
|
|
69
|
+
super();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
check(order: Order, customer: Customer): Result<void, OrderTooLarge> {
|
|
73
|
+
// …
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
## Limits
|
|
81
|
+
|
|
82
|
+
::: warning What the rule cannot see
|
|
83
|
+
- A port passed to a method of the service, call after call: the rule checks what the service
|
|
84
|
+
holds, not what it receives. In review, a domain service method takes aggregates and values.
|
|
85
|
+
- State reached through a module: the service has no field, but reads a constant that holds a
|
|
86
|
+
connection. `tactical/no-loose-code` reports the constant; the rule does not see the read.
|
|
87
|
+
:::
|
|
88
|
+
|
|
89
|
+
## Turn it off
|
|
90
|
+
|
|
91
|
+
```ts [alveolus.config.ts]
|
|
92
|
+
rules: { "tactical/no-stateful-service": "off" },
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
|
|
96
|
+
new services stay stateless while you rework the old ones.
|
|
97
|
+
|
|
98
|
+
## See also
|
|
99
|
+
|
|
100
|
+
- [Domain services](../../core/domain/domain-services.md), what is checked
|
|
101
|
+
- [Command handlers](../../core/application/command-handlers.md), which load what a service needs
|
|
102
|
+
- [Rules](../index.md), every rule by category
|