@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,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