@alveolus/arch 0.1.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.
Files changed (60) hide show
  1. package/README.md +8 -1
  2. package/dist/bin.mjs +4 -2
  3. package/dist/bin.mjs.map +1 -1
  4. package/dist/{cli-CwPCGjDg.mjs → docs-DsQHpTtV.mjs} +287 -38
  5. package/dist/docs-DsQHpTtV.mjs.map +1 -0
  6. package/dist/index.d.mts +90 -36
  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 +105 -0
  35. package/docs/guide/getting-started.md +275 -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 +183 -0
  42. package/docs/rules/layers/no-driving-shortcut.md +119 -0
  43. package/docs/rules/layers/no-impure-domain.md +189 -0
  44. package/docs/rules/layers/no-outward-import.md +184 -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 +111 -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 +106 -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-CwPCGjDg.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: () =&gt; Promise&lt;Customer&gt;</code>: what a function returns counts.</div>
42
+ <div class="al-card"><span class="al-card-title">In value object props</span><code>ValueObject&lt;{ customer: Customer }&gt;</code></div>
43
+ <div class="al-card"><span class="al-card-title">In an event payload</span><code>DomainEvent&lt;OrderId, { customer: Customer }&gt;</code></div>
44
+ </div>
45
+
46
+ The parameters of a function do not count: `onChange: (customer: Customer) => void` receives a
47
+ customer, it does not hold one.
48
+
49
+ ### One owner per entity
50
+
51
+ An entity other than a root belongs to one aggregate. The rule finds every entity each aggregate
52
+ holds, directly or through its entities and value objects, and reports an entity held by two
53
+ aggregates: the `Address` of a `Customer` cannot be held by an `Order` too.
54
+
55
+ ## What it reports
56
+
57
+ ```
58
+ src/ordering/domain/aggregates/order.aggregate.ts
59
+ 6 error tactical/no-aggregate-reference: Order.customer holds the
60
+ aggregate Customer: reference it by its identifier instead.
61
+
62
+ src/ordering/domain/value-objects/buyer.value-object.ts
63
+ 3 error tactical/no-aggregate-reference: Buyer holds the aggregate
64
+ Customer in its Props: reference it by its identifier instead.
65
+
66
+ src/ordering/domain/aggregates/order.aggregate.ts
67
+ 8 error tactical/no-aggregate-reference: Order.shipping holds the entity
68
+ Address, which Customer holds too: an entity belongs to one
69
+ aggregate.
70
+ ```
71
+
72
+ ## Fix it
73
+
74
+ ### Hold the identifier instead
75
+
76
+ So that each aggregate stays its own boundary, replace the property with the
77
+ [identifier](../../core/domain/value-objects.md#identifier) of the other aggregate, and put that
78
+ identifier in the snapshot.
79
+
80
+ <div class="al-compare">
81
+
82
+ ```ts [❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts]
83
+ import { AggregateRoot } from "@alveolus/core";
84
+
85
+ import type { Customer } from "./customer.aggregate";
86
+
87
+ export class Order extends AggregateRoot<OrderId> {
88
+ private readonly customer: Customer;
89
+ }
90
+ ```
91
+
92
+ ```ts [✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts]
93
+ import { AggregateRoot } from "@alveolus/core";
94
+
95
+ import type { CustomerId } from "../value-objects/customer-id.identifier";
96
+
97
+ export class Order extends AggregateRoot<OrderId> {
98
+ private readonly customerId: CustomerId;
99
+ }
100
+ ```
101
+
102
+ </div>
103
+
104
+ ### Give each aggregate its own entity
105
+
106
+ When two aggregates need the same kind of data, each one owns its own: the customer keeps its
107
+ `Address` entity, the order keeps a `ShippingAddress` value object copied from it when the order
108
+ is placed. Changing the customer's address no longer changes past orders.
109
+
110
+ ### Load the other aggregate in the handler
111
+
112
+ When a rule needs data from the other aggregate, the
113
+ [command handler](../../core/application/command-handlers.md) loads it through its repository and
114
+ passes what the rule needs to the business method. When the other aggregate must change too, a
115
+ second handler does it on the event, in its own transaction.
116
+
117
+ ## Limits
118
+
119
+ ::: warning What the rule cannot see
120
+ - An interface with the shape of another aggregate, such as `CustomerLike`: TypeScript types are
121
+ structural, so the rule cannot tell that a `Customer` will be passed in. Hold the identifier.
122
+ - The parameters of a callback are not followed: `onChange: (customer: Customer) => void` is
123
+ accepted, and a closure can still capture the aggregate it receives.
124
+ :::
125
+
126
+ ## Turn it off
127
+
128
+ ```ts [alveolus.config.ts]
129
+ rules: { "tactical/no-aggregate-reference": "off" },
130
+ ```
131
+
132
+ On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
133
+ new code keeps the rule while you rework the old references.
134
+
135
+ ## See also
136
+
137
+ - [Aggregates: refer to other aggregates by identity](../../core/domain/aggregates.md)
138
+ - [`tactical/no-thrown-failure`](./no-thrown-failure.md), another rule on aggregates
139
+ - [Rules](../index.md), every rule by category
@@ -0,0 +1,119 @@
1
+ ---
2
+ description: "Architecture rule for command handlers: a command handler receives command repositories, ports, event translators, domain services and value objects, never a view or another handler."
3
+ ---
4
+
5
+ # no-foreign-command-dependency
6
+
7
+ A command handler receives what changes aggregates and what they need: nothing that reads views,
8
+ and no other handler.
9
+
10
+ <dl class="al-glance">
11
+ <dt>Rule</dt><dd><code>tactical/no-foreign-command-dependency</code></dd>
12
+ <dt>Category</dt><dd><a href="/rules/#tactical">Tactical</a>: how building blocks are written</dd>
13
+ <dt>Reports</dt><dd>A command handler that receives a query repository, another handler or a class that is no building block</dd>
14
+ <dt>Applies to</dt><dd>The constructor parameters and fields of every <code>CommandHandler</code> of a core bounded context or the shared kernel</dd>
15
+ <dt>Turn off</dt><dd><a href="#turn-it-off"><code>"tactical/no-foreign-command-dependency": "off"</code></a></dd>
16
+ </dl>
17
+
18
+ ## Why
19
+
20
+ `PlaceOrderHandler` checks the order summary to decide whether the order may be placed. The view is
21
+ shaped for reading: it may be denormalised, stale or partial, and it protects no rule. Injecting
22
+ `GetOrderSummaryHandler` instead changes nothing, and injecting `PayOrderHandler` hides a second
23
+ transaction inside the first one.
24
+
25
+ ::: tip The fix
26
+ A command loads the aggregate and lets it decide. What a handler may receive is a short list:
27
+ everything else is reported, so a new way of reaching a view does not slip through.
28
+ :::
29
+
30
+ ## What it checks
31
+
32
+ Every constructor parameter and every field of each `CommandHandler`, followed into `Pick`,
33
+ generics and objects such as `deps: { … }`:
34
+
35
+ | Receives | Allowed |
36
+ | --- | --- |
37
+ | A `CommandRepository` | ✅ |
38
+ | A `Port`: `Clock`, `IdGenerator`, `UnitOfWork`, `Outbox`, your own ports | ✅ |
39
+ | An `EventTranslator`, a `DomainService` | ✅ |
40
+ | A value object, an identifier, a plain value such as a `number` | ✅ |
41
+ | A class of a package listed in `applicationDependencies` | ✅ |
42
+ | A `QueryRepository`, even though it is a `Port` | ❌ |
43
+ | An `EventPublisher`, even though it is a `Port`: events leave through the outbox, and the relay publishes them | ❌ |
44
+ | A `CommandHandler` or a `QueryHandler` | ❌ |
45
+ | Any other class of the project | ❌ |
46
+
47
+ A parameter counts by what its type extends: `OrderSummaries` extends
48
+ `QueryRepository<OrderSummary>`.
49
+
50
+ ## What it reports
51
+
52
+ ```
53
+ src/ordering/application/commands/place-order.command.ts
54
+ 2 error tactical/no-foreign-command-dependency: The CommandHandler
55
+ PlaceOrderHandler receives OrderSummaries, a QueryRepository: a
56
+ command handler receives command repositories, ports, event
57
+ translators, domain services and value objects.
58
+ ```
59
+
60
+ ## Fix it
61
+
62
+ ### Decide from the aggregate
63
+
64
+ So that the decision is taken by what keeps the rules, a command handler loads the aggregate
65
+ through its [command repository](../../core/domain/repositories.md) and lets it decide.
66
+
67
+ <div class="al-compare">
68
+
69
+ ```ts [❌ Avoid: src/ordering/application/commands/place-order.command.ts]
70
+ export class PlaceOrderHandler extends CommandHandler<PlaceOrder> {
71
+ constructor(private readonly summaries: OrderSummaries) {
72
+ super();
73
+ }
74
+ }
75
+ ```
76
+
77
+ ```ts [✅ Prefer: src/ordering/application/commands/place-order.command.ts]
78
+ export class PlaceOrderHandler extends CommandHandler<PlaceOrder> {
79
+ constructor(private readonly orders: Orders) {
80
+ super();
81
+ }
82
+ }
83
+ ```
84
+
85
+ </div>
86
+
87
+ ### React to an event instead of calling another handler
88
+
89
+ So that each command stays one transaction, a handler never calls another one. When placing an
90
+ order must also do something else, the other handler reacts to the domain event, in its own
91
+ transaction.
92
+
93
+ ## Limits
94
+
95
+ ::: warning What the rule cannot see
96
+ - A plain `Port` whose adapter reads the views: the rule sees a port, not what its adapter does.
97
+ In review, a port named like a read (`OrderStats`, `…Summary`) used by a command is a query in
98
+ disguise.
99
+ - An interface that a query repository happens to satisfy: an interface is no class, so the rule
100
+ cannot tell what will be injected. Type dependencies with the port class itself.
101
+ :::
102
+
103
+ ## Turn it off
104
+
105
+ ```ts [alveolus.config.ts]
106
+ rules: { "tactical/no-foreign-command-dependency": "off" },
107
+ ```
108
+
109
+ On an existing project, prefer a [baseline](../../guide/getting-started.md#adopt-it-on-an-existing-project):
110
+ new commands keep to their list while you rework the old ones.
111
+
112
+ ## See also
113
+
114
+ - [Command handlers](../../core/application/command-handlers.md), what is checked
115
+ - [Repositories](../../core/domain/repositories.md) and [Ports](../../core/domain/ports.md), what a
116
+ command handler receives
117
+ - [`tactical/no-foreign-query-dependency`](./no-foreign-query-dependency.md), the same list for
118
+ query handlers
119
+ - [Rules](../index.md), every rule by category
@@ -0,0 +1,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