@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,292 @@
1
+ ---
2
+ description: "Query handlers in CQRS with TypeScript: run one read that returns a view through a query repository, without loading aggregates or changing anything."
3
+ ---
4
+
5
+ # Query handlers
6
+
7
+ A query handler runs one read: it returns a [view](../domain/views.md) read through a query
8
+ repository, without loading the aggregate and without changing anything.
9
+
10
+ <dl class="al-glance">
11
+ <dt>Layer</dt><dd>Application</dd>
12
+ <dt>File</dt><dd><code>application/queries/get-order-summary.query.ts</code></dd>
13
+ <dt>Extends</dt><dd><a href="#api"><code>QueryHandler&lt;Input, Output, Error&gt;</code></a></dd>
14
+ <dt>Called by</dt><dd>Driving adapters: controllers, resolvers, scripts</dd>
15
+ <dt>Checked by</dt><dd><a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a>, <a href="/rules/tactical/no-foreign-query-dependency"><code>tactical/no-foreign-query-dependency</code></a>, <a href="/rules/layers/no-outward-import"><code>layers/no-outward-import</code></a></dd>
16
+ </dl>
17
+
18
+ ## Why
19
+
20
+ A screen lists the latest orders with their status and number of lines. Built from the `Order`
21
+ aggregate, it loads every order with all its lines, then asks the aggregate for getters it only
22
+ exposes for that screen. The aggregate grows to serve displays, and every read pays for rules it
23
+ never uses.
24
+
25
+ ::: tip The fix
26
+ A query handler reads a view: a plain shape built for the reader, read straight from storage
27
+ through a query repository. The aggregate is not loaded, and nothing is saved or published.
28
+ :::
29
+
30
+ ## How it works
31
+
32
+ A query handler receives a query, the data of one read, and returns a
33
+ [`Result`](../utilities/result.md):
34
+
35
+ <div class="al-cards">
36
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span>Read the view</span>Ask a <a href="/core/domain/repositories">query repository</a> for the view, in the shape the reader needs.</div>
37
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span>Handle what is missing</span>When nothing matches, return a <a href="/core/domain/domain-errors">domain error</a>.</div>
38
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Return it</span>Return the view in a <code>Result</code>. Nothing else happens.</div>
39
+ </div>
40
+
41
+ ```ts
42
+ async handle({
43
+ orderId,
44
+ }: GetOrderSummary): Promise<Result<OrderSummary, OrderNotFound>> {
45
+ const summary = await this.summaries.summaryOf(new OrderId(orderId));
46
+ if (summary === undefined) {
47
+ return err(new OrderNotFound({ orderId }));
48
+ }
49
+ return ok(summary);
50
+ }
51
+ ```
52
+
53
+ ## Where it fits
54
+
55
+ A query takes its own path through the system. The command side, with its aggregates, unit of
56
+ work and outbox, is never involved.
57
+
58
+ <div class="al-diagram">
59
+ <svg viewBox="0 0 680 150" role="img" aria-label="A controller calls the GetOrderSummaryHandler, which reads an OrderSummary view through the OrderSummaries query repository, which reads the storage.">
60
+ <defs>
61
+ <marker id="query-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
62
+ <path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
63
+ </marker>
64
+ </defs>
65
+ <rect class="box" x="8" y="40" width="130" height="56" rx="8" />
66
+ <text class="label" x="73" y="64" text-anchor="middle">Controller</text>
67
+ <text class="note" x="73" y="84" text-anchor="middle">driving adapter</text>
68
+ <path class="link" d="M 138 68 L 164 68" marker-end="url(#query-flow-arrow)" />
69
+ <rect class="boundary" x="166" y="40" width="190" height="56" rx="8" />
70
+ <text class="label" x="261" y="64" text-anchor="middle">GetOrderSummaryHandler</text>
71
+ <text class="note" x="261" y="84" text-anchor="middle">this page</text>
72
+ <path class="link" d="M 356 68 L 382 68" marker-end="url(#query-flow-arrow)" />
73
+ <rect class="box" x="384" y="40" width="160" height="56" rx="8" />
74
+ <text class="label" x="464" y="64" text-anchor="middle">OrderSummaries</text>
75
+ <text class="note" x="464" y="84" text-anchor="middle">query repository</text>
76
+ <path class="link" d="M 544 68 L 570 68" marker-end="url(#query-flow-arrow)" />
77
+ <rect class="box" x="572" y="40" width="100" height="56" rx="8" />
78
+ <text class="label" x="622" y="64" text-anchor="middle">Storage</text>
79
+ <text class="note" x="622" y="84" text-anchor="middle">tables</text>
80
+ <text class="note" x="340" y="130" text-anchor="middle">returns OrderSummary, a view: no aggregate loaded</text>
81
+ </svg>
82
+ </div>
83
+
84
+ ::: tip
85
+ A query handler only receives query repositories. It cannot change state, even by mistake.
86
+ :::
87
+
88
+ ## API
89
+
90
+ ```ts
91
+ import { QueryHandler } from "@alveolus/core";
92
+ // or: import { QueryHandler } from "@alveolus/core/query-handlers";
93
+ ```
94
+
95
+ ### Type parameters
96
+
97
+ ```ts
98
+ abstract class QueryHandler<
99
+ Input,
100
+ Output,
101
+ Error extends AnyDomainError = never,
102
+ > { … }
103
+ ```
104
+
105
+ | Parameter | What it is | Constraint |
106
+ | --- | --- | --- |
107
+ | `Input` | The query: the data the handler needs. | any type |
108
+ | `Output` | What the read returns, usually a view or a list of views. | any type |
109
+ | `Error` | The union of the [domain errors](../domain/domain-errors.md) it may return. | extends `DomainError`; `never` by default |
110
+
111
+ ### `constructor(…)` <Badge type="tip" text="you implement it" />
112
+
113
+ ```ts
114
+ constructor(private readonly summaries: OrderSummaries) {
115
+ super();
116
+ }
117
+ ```
118
+
119
+ `QueryHandler` declares no constructor: yours takes the query repositories it reads and calls
120
+ `super()`.
121
+
122
+ ### `handle(query)` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" /> <Badge type="tip" text="called by a driving adapter" />
123
+
124
+ ```ts
125
+ abstract handle(query: Input): Promise<Result<Output, Error>>
126
+ ```
127
+
128
+ Runs the read and returns its outcome. The driving adapter that calls it turns the `Result` into
129
+ a response.
130
+
131
+ ::: warning Caveats
132
+ - There is no separate read model: views are read from the same storage, through a query
133
+ repository.
134
+ - `Output` has no default, unlike `CommandHandler`: a read always returns something.
135
+ :::
136
+
137
+ ## Usage
138
+
139
+ Build the handler that reads the summary of an order. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
140
+
141
+ <div class="al-cards">
142
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-prepare-the-view">Prepare the view</a></span>Know what the screen reads.</div>
143
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-query">Declare the query</a></span>Say what the caller provides.</div>
144
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-declare-the-handler">Declare the handler</a></span>One class, one question.</div>
145
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-return-a-missing-view-as-a-failure">Return a missing view as a failure</a></span>No undefined for callers to check.</div>
146
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-call-it-from-a-driving-adapter">Call it from a driving adapter</a></span>Turn the Result into a response.</div>
147
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-check-it">Check it</a></span>Let the rules keep it that way.</div>
148
+ </div>
149
+
150
+ ### 1. Prepare the view
151
+
152
+ A query reads a [view](../domain/views.md), `OrderSummary`, through a query [repository](../domain/repositories.md), `OrderSummaries`: declare them first.
153
+
154
+ ### 2. Declare the query
155
+
156
+ So that a driving adapter knows what to provide, the query is a plain type named after the question, in the file of its handler.
157
+
158
+ ```ts [src/ordering/application/queries/get-order-summary.query.ts]
159
+ export interface GetOrderSummary {
160
+ readonly orderId: string;
161
+ }
162
+ ```
163
+
164
+ ### 3. Declare the handler
165
+
166
+ The handler extends `QueryHandler` and receives the query repository as an abstract class. It reads the view and returns it, without loading an aggregate.
167
+
168
+ ```ts [src/ordering/application/queries/get-order-summary.query.ts]
169
+ import { ok, QueryHandler, type Result } from "@alveolus/core"; // [!code ++]
170
+
171
+ import { // [!code ++]
172
+ OrderSummaries, // [!code ++]
173
+ } from "../../domain/repositories/order-summaries.repository"; // [!code ++]
174
+ import { OrderId } from "../../domain/value-objects/order-id.identifier"; // [!code ++]
175
+ import type { OrderSummary } from "../../domain/views/order-summary.view"; // [!code ++]
176
+
177
+ export interface GetOrderSummary {
178
+ readonly orderId: string;
179
+ }
180
+
181
+ export class GetOrderSummaryHandler extends QueryHandler< // [!code ++]
182
+ GetOrderSummary, // [!code ++]
183
+ OrderSummary | undefined // [!code ++]
184
+ > { // [!code ++]
185
+ constructor(private readonly summaries: OrderSummaries) { // [!code ++]
186
+ super(); // [!code ++]
187
+ } // [!code ++]
188
+
189
+ async handle({ // [!code ++]
190
+ orderId, // [!code ++]
191
+ }: GetOrderSummary): Promise< // [!code ++]
192
+ Result<OrderSummary | undefined, never> // [!code ++]
193
+ > { // [!code ++]
194
+ return ok( // [!code ++]
195
+ await this.summaries.summaryOf(new OrderId(orderId)), // [!code ++]
196
+ ); // [!code ++]
197
+ } // [!code ++]
198
+ } // [!code ++]
199
+ ```
200
+
201
+ ### 4. Return a missing view as a failure
202
+
203
+ Returning `undefined` pushes the check to every caller, who may forget it. The handler declares `OrderNotFound` instead, so the signature says what can go wrong.
204
+
205
+ ```ts [src/ordering/application/queries/get-order-summary.query.ts]
206
+ import { ok, QueryHandler, type Result } from "@alveolus/core"; // [!code --]
207
+ import { err, ok, QueryHandler, type Result } from "@alveolus/core"; // [!code ++]
208
+
209
+ import { OrderNotFound } from "../../domain/errors/order-not-found.error"; // [!code ++]
210
+ import {
211
+ OrderSummaries,
212
+ } from "../../domain/repositories/order-summaries.repository";
213
+ import { OrderId } from "../../domain/value-objects/order-id.identifier";
214
+ import type { OrderSummary } from "../../domain/views/order-summary.view";
215
+
216
+ export interface GetOrderSummary {
217
+ readonly orderId: string;
218
+ }
219
+
220
+ export class GetOrderSummaryHandler extends QueryHandler<
221
+ GetOrderSummary,
222
+ OrderSummary | undefined // [!code --]
223
+ OrderSummary, // [!code ++]
224
+ OrderNotFound // [!code ++]
225
+ > {
226
+ constructor(private readonly summaries: OrderSummaries) {
227
+ super();
228
+ }
229
+
230
+ async handle({
231
+ orderId,
232
+ }: GetOrderSummary): Promise< // [!code --]
233
+ Result<OrderSummary | undefined, never> // [!code --]
234
+ > { // [!code --]
235
+ return ok( // [!code --]
236
+ await this.summaries.summaryOf(new OrderId(orderId)), // [!code --]
237
+ }: GetOrderSummary): Promise<Result<OrderSummary, OrderNotFound>> { // [!code ++]
238
+ const summary = await this.summaries.summaryOf( // [!code ++]
239
+ new OrderId(orderId), // [!code ++]
240
+ );
241
+ if (summary === undefined) { // [!code ++]
242
+ return err(new OrderNotFound({ orderId })); // [!code ++]
243
+ } // [!code ++]
244
+ return ok(summary); // [!code ++]
245
+ }
246
+ }
247
+ ```
248
+
249
+ This is the complete handler.
250
+
251
+ ### 5. Call it from a driving adapter
252
+
253
+ A controller builds the query, calls `handle` and turns the `Result` into a response: the failure becomes a 404 there, and nowhere else.
254
+
255
+ ```ts [src/ordering/driving/http/controllers/orders.controller.ts]
256
+ const summary = await this.getOrderSummary.handle({ orderId });
257
+ if (!summary.ok) {
258
+ return { status: 404, body: { error: summary.error.type } };
259
+ }
260
+ return { status: 200, body: summary.value };
261
+ ```
262
+
263
+ ### 6. Check it
264
+
265
+ Run the checks. Three rules keep the handler the way it is now:
266
+
267
+ ```sh
268
+ npx alveolus arch check
269
+ ```
270
+
271
+ <div class="al-cards">
272
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-foreign-query-dependency"><code>no-foreign-query-dependency</code></a></span>It receives query repositories, ports that do not write and value objects: a read never writes.</div>
273
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-misplaced-class"><code>no-misplaced-class</code></a></span>It stays alone in <code>application/queries/*.query.ts</code>.</div>
274
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-outward-import"><code>no-outward-import</code></a></span>It imports the domain and the application, never an adapter.</div>
275
+ </div>
276
+
277
+ A command repository added to its constructor is reported:
278
+
279
+ ```
280
+ src/ordering/application/queries/get-order-summary.query.ts
281
+ 20 error tactical/no-foreign-query-dependency: The QueryHandler
282
+ GetOrderSummaryHandler receives Orders, a CommandRepository: a
283
+ query handler receives query repositories, ports that do not
284
+ write, and value objects.
285
+ ```
286
+
287
+ ## See also
288
+
289
+ - [Views](../domain/views.md), what a query returns
290
+ - [Repositories](../domain/repositories.md), for `QueryRepository`
291
+ - [Command handlers](./command-handlers.md), for requests that change state
292
+ - Rules: [`tactical/no-foreign-query-dependency`](../../rules/tactical/no-foreign-query-dependency.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)
@@ -0,0 +1,352 @@
1
+ ---
2
+ description: "The Unit of Work pattern in TypeScript: run all the writes of one use case in a single transaction, so all of them are kept or none."
3
+ ---
4
+
5
+ # Unit of Work
6
+
7
+ A unit of work runs the writes of one use case in one transaction: all of them are kept, or none.
8
+
9
+ <dl class="al-glance">
10
+ <dt>Layer</dt><dd>Application (a port)</dd>
11
+ <dt>File</dt><dd><code>shared-kernel/driven/pg/adapters/pg-unit-of-work.adapter.ts</code> (your adapter)</dd>
12
+ <dt>Extends</dt><dd><a href="#api"><code>UnitOfWork</code></a></dd>
13
+ <dt>Called by</dt><dd><a href="/core/application/command-handlers">Command handlers</a></dd>
14
+ <dt>Checked by</dt><dd><a href="/rules/layers/no-portless-adapter"><code>layers/no-portless-adapter</code></a>, <a href="/rules/tactical/no-misplaced-class"><code>tactical/no-misplaced-class</code></a></dd>
15
+ </dl>
16
+
17
+ ## Why
18
+
19
+ Placing an order writes twice: the `Order` is saved, then its `OrderPlaced` event is added to the
20
+ [outbox](./outbox.md). If the process stops between the two, the order is placed and nobody hears
21
+ about it. If the second write fails, the first one stays.
22
+
23
+ ::: tip The fix
24
+ The command handler runs its work inside `unitOfWork.run(…)`. Every write made inside belongs to the
25
+ same transaction. The work returns a [`Result`](../utilities/result.md): `ok` commits, `err` rolls
26
+ back.
27
+ :::
28
+
29
+ ## How it works
30
+
31
+ `run` opens a transaction, runs the work, and reads its outcome:
32
+
33
+ <div class="al-cards">
34
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><code>ok</code>: commit</span>The writes are kept, and the result is returned.</div>
35
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><code>err</code>: roll back</span>A business failure, such as <code>EmptyOrder</code>. Nothing is kept, and the same <code>err</code> is returned.</div>
36
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span>Thrown: roll back</span>A bug or a lost connection. Nothing is kept, and the error is thrown again.</div>
37
+ </div>
38
+
39
+ ```ts
40
+ return this.unitOfWork.run(async () => {
41
+ await this.orders.save(order);
42
+ await this.outbox.add(events);
43
+ return ok();
44
+ });
45
+ ```
46
+
47
+ The unit of work tracks nothing: it does not know which aggregates were loaded. The handler saves
48
+ each change explicitly, inside `run`.
49
+
50
+ ## Where it fits
51
+
52
+ The unit of work wraps the whole PlaceOrder use case: loading the order, placing it, saving it and
53
+ adding its events to the outbox.
54
+
55
+ <div class="al-diagram">
56
+ <svg viewBox="0 0 680 320" role="img" aria-label="A request goes from a controller to the PlaceOrderHandler. One unit of work wraps its four steps: load the Order, call order.place, save the order and add its events to the outbox. It commits on ok and rolls back otherwise.">
57
+ <defs>
58
+ <marker id="uow-flow-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
59
+ <path class="arrow" d="M 0 0 L 10 5 L 0 10 z" />
60
+ </marker>
61
+ </defs>
62
+ <rect class="boundary" x="426" y="8" width="252" height="304" rx="12" />
63
+ <text class="note" x="552" y="28" text-anchor="middle">unitOfWork.run(…) · this page</text>
64
+ <rect class="box" x="8" y="132" width="130" height="56" rx="8" />
65
+ <text class="label" x="73" y="156" text-anchor="middle">Controller</text>
66
+ <text class="note" x="73" y="176" text-anchor="middle">driving adapter</text>
67
+ <path class="link" d="M 138 160 L 178 160" marker-end="url(#uow-flow-arrow)" />
68
+ <rect class="box" x="180" y="132" width="180" height="56" rx="8" />
69
+ <text class="label" x="270" y="156" text-anchor="middle">PlaceOrderHandler</text>
70
+ <text class="note" x="270" y="176" text-anchor="middle">command handler</text>
71
+ <rect class="box" x="440" y="40" width="224" height="48" rx="8" />
72
+ <text class="label" x="552" y="60" text-anchor="middle">1 · orders.findById(id)</text>
73
+ <text class="note" x="552" y="78" text-anchor="middle">loads the Order</text>
74
+ <rect class="box" x="440" y="102" width="224" height="48" rx="8" />
75
+ <text class="label" x="552" y="122" text-anchor="middle">2 · order.place(…)</text>
76
+ <text class="note" x="552" y="140" text-anchor="middle">rules + event</text>
77
+ <rect class="box" x="440" y="164" width="224" height="48" rx="8" />
78
+ <text class="label" x="552" y="184" text-anchor="middle">3 · orders.save(order)</text>
79
+ <text class="note" x="552" y="202" text-anchor="middle">same transaction</text>
80
+ <rect class="box" x="440" y="226" width="224" height="48" rx="8" />
81
+ <text class="label" x="552" y="246" text-anchor="middle">4 · outbox.add(events)</text>
82
+ <text class="note" x="552" y="264" text-anchor="middle">same transaction</text>
83
+ <text class="note" x="552" y="298" text-anchor="middle">ok → commit · err → rollback</text>
84
+ <path class="link" d="M 360 160 L 424 160" marker-end="url(#uow-flow-arrow)" />
85
+ </svg>
86
+ </div>
87
+
88
+ ::: tip
89
+ One transaction per use case. A command handler never calls another one: two nested `run` calls
90
+ open two transactions.
91
+ :::
92
+
93
+ ## API
94
+
95
+ ```ts
96
+ import { Transaction, UnitOfWork } from "@alveolus/core";
97
+ // or: from "@alveolus/core/unit-of-work"
98
+ ```
99
+
100
+ ### Type parameters
101
+
102
+ ```ts
103
+ abstract class UnitOfWork extends Port {
104
+ run<T, E>(
105
+ work: () => Promise<Result<T, E>>,
106
+ ): Promise<Result<T, E>>;
107
+ }
108
+ ```
109
+
110
+ | Parameter | What it is | Constraint |
111
+ | --- | --- | --- |
112
+ | `T` | The value of a success. | inferred from `work` |
113
+ | `E` | The error of a failure. | inferred from `work` |
114
+
115
+ ### `begin()` <Badge type="info" text="protected · abstract" /> <Badge type="tip" text="you implement it" />
116
+
117
+ ```ts
118
+ protected abstract begin(): Promise<Transaction>
119
+ ```
120
+
121
+ Opens a transaction and returns it. `run` calls it once per call.
122
+
123
+ ### `Transaction.commit()` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" />
124
+
125
+ ```ts
126
+ abstract commit(): Promise<void>
127
+ ```
128
+
129
+ Makes the writes of the transaction permanent.
130
+
131
+ ### `Transaction.rollback()` <Badge type="info" text="abstract" /> <Badge type="tip" text="you implement it" />
132
+
133
+ ```ts
134
+ abstract rollback(): Promise<void>
135
+ ```
136
+
137
+ Discards the writes of the transaction.
138
+
139
+ ### `run(work)` <Badge type="tip" text="called by the command handler" />
140
+
141
+ ```ts
142
+ run<T, E>(
143
+ work: () => Promise<Result<T, E>>,
144
+ ): Promise<Result<T, E>>
145
+ ```
146
+
147
+ Begins a transaction and runs `work`. Commits when it returns `ok`, rolls back when it returns
148
+ `err`, and rolls back then throws again when it throws.
149
+
150
+ ::: warning Caveats
151
+ - The unit of work tracks nothing: the handler saves each aggregate explicitly inside `run`.
152
+ - A failure to commit is technical: it is thrown.
153
+ - Nested calls to `run` open nested transactions through `begin`. Avoid calling a command handler
154
+ from another one.
155
+ :::
156
+
157
+ ## Usage
158
+
159
+ Build a unit of work in PostgreSQL. Each step shows the whole file: added lines are highlighted, replaced lines are struck out.
160
+
161
+ <div class="al-cards">
162
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">1</span><a href="#_1-choose-how-to-share-the-connection">Choose how to share the connection</a></span>One connection per transaction.</div>
163
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">2</span><a href="#_2-declare-the-adapter">Declare the adapter</a></span>Extend the port.</div>
164
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">3</span><a href="#_3-open-a-transaction">Open a transaction</a></span>Begin, then commit or roll back.</div>
165
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">4</span><a href="#_4-share-the-connection">Share the connection</a></span>Repositories and outbox write in it.</div>
166
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">5</span><a href="#_5-release-the-connection">Release the connection</a></span>Give it back to the pool.</div>
167
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">6</span><a href="#_6-wire-it-and-run-in-it">Wire it and run in it</a></span>Same storage everywhere.</div>
168
+ <div class="al-card"><span class="al-card-title"><span class="al-card-step">7</span><a href="#_7-check-it">Check it</a></span>Let the rules keep it that way.</div>
169
+ </div>
170
+
171
+ ### 1. Choose how to share the connection
172
+
173
+ The repositories and the outbox must write on the connection of the transaction without receiving it: an `AsyncLocalStorage`, created once by the composition root, carries it.
174
+
175
+ ### 2. Declare the adapter
176
+
177
+ The adapter extends `UnitOfWork` and receives the pool and the storage of the current connection. `run` is already written: you only open the transaction.
178
+
179
+ ```ts [src/shared-kernel/driven/pg/adapters/pg-unit-of-work.adapter.ts]
180
+ import type { AsyncLocalStorage } from "node:async_hooks";
181
+
182
+ import { UnitOfWork } from "@alveolus/core";
183
+ import type { Pool, PoolClient } from "pg";
184
+
185
+ export class PgUnitOfWork extends UnitOfWork {
186
+ constructor(
187
+ private readonly pool: Pool,
188
+ private readonly current: AsyncLocalStorage<PoolClient>,
189
+ ) {
190
+ super();
191
+ }
192
+ }
193
+ ```
194
+
195
+ TypeScript now asks for `begin()`: the next step adds it.
196
+
197
+ ### 3. Open a transaction
198
+
199
+ `begin` opens a transaction on a connection of the pool and returns how to end it. `Transaction` has no state of its own, so a plain object is enough: no second class.
200
+
201
+ ```ts [src/shared-kernel/driven/pg/adapters/pg-unit-of-work.adapter.ts]
202
+ import type { AsyncLocalStorage } from "node:async_hooks";
203
+
204
+ import { UnitOfWork } from "@alveolus/core"; // [!code --]
205
+ import { type Transaction, UnitOfWork } from "@alveolus/core"; // [!code ++]
206
+ import type { Pool, PoolClient } from "pg";
207
+
208
+ export class PgUnitOfWork extends UnitOfWork {
209
+ constructor(
210
+ private readonly pool: Pool,
211
+ private readonly current: AsyncLocalStorage<PoolClient>,
212
+ ) {
213
+ super();
214
+ }
215
+
216
+ protected async begin(): Promise<Transaction> { // [!code ++]
217
+ const client = await this.pool.connect(); // [!code ++]
218
+ await client.query("BEGIN"); // [!code ++]
219
+ return { // [!code ++]
220
+ commit: async () => { // [!code ++]
221
+ await client.query("COMMIT"); // [!code ++]
222
+ }, // [!code ++]
223
+ rollback: async () => { // [!code ++]
224
+ await client.query("ROLLBACK"); // [!code ++]
225
+ }, // [!code ++]
226
+ }; // [!code ++]
227
+ } // [!code ++]
228
+ }
229
+ ```
230
+
231
+ ### 4. Share the connection
232
+
233
+ So that every adapter called inside `run` writes in the same transaction, `begin` puts the connection in the storage they read.
234
+
235
+ ```ts [src/shared-kernel/driven/pg/adapters/pg-unit-of-work.adapter.ts]
236
+ import type { AsyncLocalStorage } from "node:async_hooks";
237
+
238
+ import { type Transaction, UnitOfWork } from "@alveolus/core";
239
+ import type { Pool, PoolClient } from "pg";
240
+
241
+ export class PgUnitOfWork extends UnitOfWork {
242
+ constructor(
243
+ private readonly pool: Pool,
244
+ private readonly current: AsyncLocalStorage<PoolClient>,
245
+ ) {
246
+ super();
247
+ }
248
+
249
+ protected async begin(): Promise<Transaction> {
250
+ const client = await this.pool.connect();
251
+ await client.query("BEGIN");
252
+ this.current.enterWith(client); // [!code ++]
253
+ return {
254
+ commit: async () => {
255
+ await client.query("COMMIT");
256
+ },
257
+ rollback: async () => {
258
+ await client.query("ROLLBACK");
259
+ },
260
+ };
261
+ }
262
+ }
263
+ ```
264
+
265
+ ### 5. Release the connection
266
+
267
+ A connection that is never released exhausts the pool. Each way out of the transaction gives it back.
268
+
269
+ ```ts [src/shared-kernel/driven/pg/adapters/pg-unit-of-work.adapter.ts]
270
+ import type { AsyncLocalStorage } from "node:async_hooks";
271
+
272
+ import { type Transaction, UnitOfWork } from "@alveolus/core";
273
+ import type { Pool, PoolClient } from "pg";
274
+
275
+ export class PgUnitOfWork extends UnitOfWork {
276
+ constructor(
277
+ private readonly pool: Pool,
278
+ private readonly current: AsyncLocalStorage<PoolClient>,
279
+ ) {
280
+ super();
281
+ }
282
+
283
+ protected async begin(): Promise<Transaction> {
284
+ const client = await this.pool.connect();
285
+ await client.query("BEGIN");
286
+ this.current.enterWith(client);
287
+ return {
288
+ commit: async () => {
289
+ await client.query("COMMIT");
290
+ client.release(); // [!code ++]
291
+ },
292
+ rollback: async () => {
293
+ await client.query("ROLLBACK");
294
+ client.release(); // [!code ++]
295
+ },
296
+ };
297
+ }
298
+ }
299
+ ```
300
+
301
+ This is the complete unit of work.
302
+
303
+ ### 6. Wire it and run in it
304
+
305
+ The composition root passes the same storage to the unit of work and to every adapter that writes in it:
306
+
307
+ ```ts [src/app.module.ts]
308
+ const current = new AsyncLocalStorage<PoolClient>();
309
+ const unitOfWork = new PgUnitOfWork(pool, current);
310
+ const outbox = new PgOutbox(pool, current);
311
+ ```
312
+
313
+ The [command handler](./command-handlers.md#usage) then wraps its work in `run`: a returned failure or a thrown error rolls everything back.
314
+
315
+ ```ts [src/ordering/application/commands/place-order.command.ts]
316
+ return this.unitOfWork.run(async () => {
317
+ const order = await this.orders.findById(new OrderId(orderId));
318
+ …
319
+ await this.orders.save(order);
320
+ await this.outbox.add(events);
321
+ return ok();
322
+ });
323
+ ```
324
+
325
+ ### 7. Check it
326
+
327
+ Run the checks. Two rules keep the unit of work the way it is now:
328
+
329
+ ```sh
330
+ npx alveolus arch check
331
+ ```
332
+
333
+ <div class="al-cards">
334
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/layers/no-portless-adapter"><code>no-portless-adapter</code></a></span>It extends the port it implements.</div>
335
+ <div class="al-card"><span class="al-card-title"><a href="../../rules/tactical/no-misplaced-class"><code>no-misplaced-class</code></a></span>It stays alone in its file: no second class for the transaction.</div>
336
+ </div>
337
+
338
+ A transaction class declared in the same file is reported:
339
+
340
+ ```
341
+ src/shared-kernel/driven/pg/adapters/pg-unit-of-work.adapter.ts
342
+ 30 error tactical/no-misplaced-class: PgTransaction shares its file
343
+ with PgUnitOfWork: one class per file.
344
+ ```
345
+
346
+ ## See also
347
+
348
+ - [Outbox](./outbox.md), written in the same transaction
349
+ - [Command handlers](./command-handlers.md), which run their work in it
350
+ - [Repositories](../domain/repositories.md), which write through it
351
+ - [Result](../utilities/result.md), whose outcome decides commit or rollback
352
+ - Rules: [`layers/no-portless-adapter`](../../rules/layers/no-portless-adapter.md), [`tactical/no-misplaced-class`](../../rules/tactical/no-misplaced-class.md)