@wairon/cli 5.1.1-dev.101 → 5.1.1-dev.103
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -45
- package/dist/cli/index.js +2186 -687
- package/dist/cli/index.js.map +1 -1
- package/dist/index.d.ts +37667 -0
- package/dist/index.js +1613 -536
- package/dist/index.js.map +1 -1
- package/dist/templates/architect.yaml +14 -12
- package/dist/templates/domain-owner.yaml +3 -2
- package/dist/templates/skills/sdd-architect.md +11 -5
- package/dist/templates/skills/sdd-auditor.md +5 -4
- package/dist/templates/skills/sdd-delegate.md +3 -2
- package/dist/templates/skills/sdd-implement.md +4 -4
- package/dist/templates/skills/sdd-narrative.md +1 -1
- package/dist/webapp.html +770 -0
- package/docs/architecture.md +83 -0
- package/docs/cli.md +771 -0
- package/docs/design-export.md +308 -0
- package/docs/extending-wairon.md +615 -0
- package/docs/requirements.md +65 -0
- package/docs/roadmap.md +133 -0
- package/docs/standards/INDEX.md +23 -0
- package/docs/standards/architecture.md +953 -0
- package/docs/standards/language-bindings.md +120 -0
- package/docs/templates.md +61 -0
- package/docs/vision.md +59 -0
- package/docs/wairon-feature-requests-hosted-mcp.md +74 -0
- package/package.json +5 -2
- package/schemas/design-export-1.json +4 -4
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
# wairon — Architecture Standard
|
|
2
|
+
|
|
3
|
+
> The canonical, language-neutral definition of wairon's architecture model: the
|
|
4
|
+
> building blocks, the patterns, the entity/behavior rules, the spec-tree layout,
|
|
5
|
+
> and the rules a conformant design must obey. The schema, the validator, the MCP
|
|
6
|
+
> tools, and the SDD skills all reconcile to this document.
|
|
7
|
+
>
|
|
8
|
+
> This standard describes *how to structure any system designed with wairon*. It
|
|
9
|
+
> is language-agnostic — the structural rules apply to any OOP or structured
|
|
10
|
+
> language. Language-specific realization (concurrency, memory) lives in
|
|
11
|
+
> [language-bindings.md](language-bindings.md).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 1. Two orthogonal axes: containment and refinement
|
|
16
|
+
|
|
17
|
+
Everything is positioned on **two independent axes**. Conflating them is the most
|
|
18
|
+
common source of confusion, so they come first.
|
|
19
|
+
|
|
20
|
+
- **Containment (the tree → folders):** what contains what —
|
|
21
|
+
`System ⊃ Subsystem ⊃ Component ⊃ owned blocks`.
|
|
22
|
+
- **Refinement (level of detail → files):** for *one* component, increasing
|
|
23
|
+
detail — `structure → interface → implementation + narrative`.
|
|
24
|
+
|
|
25
|
+
The labels L0–L5 walk down containment first, then refinement:
|
|
26
|
+
|
|
27
|
+
| Label | Axis | Artifact |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| **L0 System** | containment | `.index.yaml` (specs root) |
|
|
30
|
+
| **L1 Subsystem** | containment | `<subsystem>/.index.yaml` |
|
|
31
|
+
| **L2 Component** | containment leaf **and** refinement root (structure) | `<component>/.index.yaml` |
|
|
32
|
+
| **L3 Interface** | refinement | `<component>/.interface.yaml` |
|
|
33
|
+
| **L4 Implementation** | refinement | `<component>/.implementation.yaml` |
|
|
34
|
+
| **L5 Narrative** | refinement (a *sub-field* of L4, not a separate file) | steps inside `.implementation.yaml` |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## 2. The spec tree on disk
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
.wai/specs/
|
|
42
|
+
.index.yaml ← L0 system: vision, boundaries, global requirements
|
|
43
|
+
types/ ← shared value objects (Money, Address, Email …)
|
|
44
|
+
money.yaml
|
|
45
|
+
<subsystem>/ ← L1 a bounded context / service
|
|
46
|
+
.index.yaml ← L1 public interface(s) the service exposes
|
|
47
|
+
types/ ← entities/aggregates owned by THIS subsystem (e.g. order.yaml)
|
|
48
|
+
<component>/ ← L2 a standalone block OR a pattern facade
|
|
49
|
+
.index.yaml ← L2 structure: role/stereotype, responsibility, owns, dependsOn
|
|
50
|
+
.interface.yaml ← L3 contract: method signatures (referencing types by id)
|
|
51
|
+
.implementation.yaml ← L4+L5 sourcePath, concurrency variant, per-method narrative
|
|
52
|
+
<owned-block>/ ← (only if this component is a pattern) a private owned block
|
|
53
|
+
.index.yaml
|
|
54
|
+
.interface.yaml
|
|
55
|
+
.implementation.yaml
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**File contents:**
|
|
59
|
+
- `.index.yaml` (L2) — identity, **role/stereotype**, **responsibility description**, `owns` (member blocks; patterns only), `dependsOn` (collaborators). No methods, no narratives.
|
|
60
|
+
- `.interface.yaml` (L3) — **method signatures** (name, params/returns referencing types, optional HTTP/gRPC/event binding). Interfaces are **method contracts only** — fields are implementation, never part of the contract.
|
|
61
|
+
- `.implementation.yaml` (L4+L5) — `sourcePath`, the **concurrency variant**, and the **per-method narrative** (L5 steps).
|
|
62
|
+
|
|
63
|
+
Different agents own different refinement files: the **architect** owns
|
|
64
|
+
`.index.yaml` + `.interface.yaml`; the **implementer** owns `.implementation.yaml`. (Legacy undotted names — `system.yaml`, `subsystem.yaml`, `component.yaml`, `interface.yaml`, `implementation.yaml` — still load; `wairon doctor --fix` migrates them.)
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## 3. Building blocks (the primitives)
|
|
69
|
+
|
|
70
|
+
These ten are the **only** atomic component types.
|
|
71
|
+
|
|
72
|
+
| Block | Role | Owns | Default interface |
|
|
73
|
+
|---|---|---|---|
|
|
74
|
+
| **Portal** | The service's **inbound** front door — receives external commands and dispatches them | the inbound transport binding + dependencies | `dispatch(command) → result` |
|
|
75
|
+
| **Orchestrator** | **Logic as a flowchart** — ordered steps, conditions, branches, loops and parallel dispatch — calling its collaborators, other Orchestrators among them, nested down to leaf logic. One or more cohesive methods sharing those collaborators, realized as a class or a module of functions. Its `dependencyClass` (`pure` or `read`) bounds what it may depend on; unset, it is a **workflow** | dependencies only | domain-specific methods |
|
|
76
|
+
| **Supervisor** | Owns the **set** of live Actors and their lifecycle; may supervise other Supervisors | runtime state (the set of Actors) | `start/stop`, find-or-start by id |
|
|
77
|
+
| **Actor** | Owns **one** live thing — a session, a connection, a timer, or an entity instance — and handles its events one at a time. Its methods are full flowcharts, not shims | that thing's runtime state | `start/stop` + its messages |
|
|
78
|
+
| **Store** | Authoritative state boundary for **one aggregate** | the aggregate's entities | `get`, `list`, `write`, `remove` |
|
|
79
|
+
| **Index** | Read path; optimized derived lookup maps over a Store | derived lookup state | `get_by_<key>`, `list_by_<key>` |
|
|
80
|
+
| **Query** | A **Repository member** for computed reads over its Store — results the backend computes per call (an aggregate, a ranking, a report) | dependencies only | one method per computed read |
|
|
81
|
+
| **Registry** | Write path (CUD) for one aggregate | dependencies only | `create`, `update`, `delete` |
|
|
82
|
+
| **Adapter** | A **client to an external system/protocol** (DB, FS, HTTP, gRPC, WS, message bus). The **only** block doing raw external I/O | a connection/resource handle | protocol-shaped (`query`/`execute`, `send`/`receive`, `publish`…) |
|
|
83
|
+
| **Observer** | Subscribes to events and forwards them to one workflow | a subscription | `on_event(event)` |
|
|
84
|
+
|
|
85
|
+
### State vs dependencies vs workflow
|
|
86
|
+
|
|
87
|
+
Every component holds **dependencies** (its injected collaborators). What differs
|
|
88
|
+
is whether it also owns **state** or **workflow**:
|
|
89
|
+
|
|
90
|
+
| Owns domain/runtime state | Owns workflow | Owns only dependencies (stateless paths and boundaries) |
|
|
91
|
+
|---|---|---|
|
|
92
|
+
| Store (authoritative), Index (derived); Supervisor, Actor (runtime) | Orchestrator | Registry, Query, Portal, Observer, Adapter* |
|
|
93
|
+
|
|
94
|
+
\*The Adapter holds a *resource handle* (a connection), not domain state.
|
|
95
|
+
"Stateless" means **no domain state** — not "no fields."
|
|
96
|
+
|
|
97
|
+
An **Orchestrator holds only its collaborators**: whatever one call computes lives
|
|
98
|
+
on that call's stack. Runtime state — a live session, a timer, the set of running
|
|
99
|
+
instances — belongs to an **Actor** or a **Supervisor**, whose own methods run
|
|
100
|
+
logic over it; domain state belongs to a **Store**. State an Orchestrator keeps
|
|
101
|
+
for itself between calls is hidden state, invisible to every rule in this
|
|
102
|
+
standard.
|
|
103
|
+
|
|
104
|
+
### Dependency rules
|
|
105
|
+
|
|
106
|
+
- **Pure logic** — an Orchestrator with `dependencyClass: pure` — may be used by
|
|
107
|
+
**every** block: a Store, Registry, Index or Query validating or deriving
|
|
108
|
+
through it, an Adapter translating with it. It depends only on other pure
|
|
109
|
+
Orchestrators, so it never drags data, I/O or held state into its caller.
|
|
110
|
+
- **Portal** is the inbound boundary; no *local* component may depend on it. It
|
|
111
|
+
dispatches to Orchestrators and may consult Repository and Index **read** faces
|
|
112
|
+
for passthrough reads; a write reached from a Portal's narrative or dispatch
|
|
113
|
+
table — or a lifecycle change — routes through an Orchestrator
|
|
114
|
+
(`PORTAL_WRITE_SHORTCUT`). It never depends
|
|
115
|
+
on a Store, Registry, Adapter or Query. A Portal may **message a Supervisor by
|
|
116
|
+
id** to reach a live Actor (below).
|
|
117
|
+
The **one** exception to "no component depends on a Portal" is a
|
|
118
|
+
**cross-subsystem client Adapter** (see below): from its side the remote
|
|
119
|
+
subsystem's Portal is an external front door, so it depends on that Portal —
|
|
120
|
+
never on the remote subsystem's internals.
|
|
121
|
+
- **Observer** may depend on exactly **one** Orchestrator *or* one Supervisor
|
|
122
|
+
entry; it may use a message-bus **Adapter** to subscribe. No component depends on it.
|
|
123
|
+
- **Orchestrator** — its `dependencyClass` bounds its dependencies. The class is
|
|
124
|
+
checked on every hop, so by induction nothing below pure logic reaches data or I/O:
|
|
125
|
+
- `pure` depends only on pure Orchestrators;
|
|
126
|
+
- `read` also depends on read Orchestrators, Repositories, Indexes and Adapters,
|
|
127
|
+
and never calls their write methods (judged once facade methods carry effect tags);
|
|
128
|
+
- unset, it is a **workflow**: it may depend on whatever the other rules allow —
|
|
129
|
+
Repositories, other Orchestrators, Adapters, and Supervisors to reach live Actors.
|
|
130
|
+
- **Supervisor** depends on its Actors, Orchestrators, Adapters and other
|
|
131
|
+
Supervisors, and never on a presentation block
|
|
132
|
+
(`ARCHITECTURE_VIOLATION_SUPERVISOR_DEP`). Its held state has two homes:
|
|
133
|
+
- its **supervision state** — restart counts, live sets, run brackets — is a
|
|
134
|
+
Store or Registry it **owns** (`owns`, one hop): private, read and written in
|
|
135
|
+
full. A Supervisor owns nothing else (`SUPERVISOR_CONTAINMENT`), and nobody
|
|
136
|
+
else depends on that state: a component that does is the intruder, and the
|
|
137
|
+
finding is reported on it, naming the Supervisor and the state
|
|
138
|
+
(`SUPERVISION_STATE_INTRUSION`);
|
|
139
|
+
- **shared data** — a data component it does not own — it reaches only through
|
|
140
|
+
`read`- and `lifecycle`-effect methods. A write to an entity's fields goes
|
|
141
|
+
through a workflow: the Supervisor depends on the Orchestrator that does it
|
|
142
|
+
(`SUPERVISOR_WRITE_SHORTCUT`, judged from its narrative call steps against the
|
|
143
|
+
callee's declared effect; a callee that declares no effect is refused too).
|
|
144
|
+
A read made on behalf of a request still passes; in practice the paired write
|
|
145
|
+
forces the workflow out, and the read moves with it.
|
|
146
|
+
- **Actor** — a live Actor is **reached through its supervision**. A caller finds
|
|
147
|
+
it by id through a **Registry its Supervisor maintains** — owns, or keeps
|
|
148
|
+
through lifecycle calls (register and unregister a live handle) — so the
|
|
149
|
+
caller's `dependsOn` lists that Registry and the Actor: the lookup hop the code
|
|
150
|
+
really takes. Depending on a Supervisor that supervises the Actor (messaging it
|
|
151
|
+
by id) is reach too; anything else is `ACTOR_REACHED_WITHOUT_SUPERVISOR`. A
|
|
152
|
+
Supervisor depending on the Actor supervises it. The Actor itself may use
|
|
153
|
+
Repository facades, Indexes, Adapters and Orchestrators, and never depends on
|
|
154
|
+
its own Supervisor.
|
|
155
|
+
- **Method effects.** A contract method's `effect` says what it does to held
|
|
156
|
+
state: `read` observes it; `write` modifies an entity's domain fields;
|
|
157
|
+
`lifecycle` creates, destroys, or (un)registers an entity's existence or
|
|
158
|
+
membership and never modifies its domain fields. The lifecycle effect is
|
|
159
|
+
**closed under composition**: a lifecycle method calls only read and lifecycle
|
|
160
|
+
methods besides its construction and local steps (`LIFECYCLE_CALLS_WRITE`).
|
|
161
|
+
Both `write` and `lifecycle` are mutations: a Portal's read shortcut covers
|
|
162
|
+
neither, and a durable Store needs a hydration read-back for both.
|
|
163
|
+
- **Store** may depend on an **Adapter** (its backend), another Store, or pure
|
|
164
|
+
logic. It is depended *upon* by Registries, Indexes and Queries — never the reverse.
|
|
165
|
+
- **Index** is a read-only **projection over a Store**: it shares the Store's
|
|
166
|
+
per-entry references. It may depend on its Store, an Adapter for cold reads, and
|
|
167
|
+
pure logic. It **never** depends on a Registry — read and write paths are decoupled.
|
|
168
|
+
As an **exceptional case**, when one Index's projection is itself worth
|
|
169
|
+
re-presenting another way, a derived Index may depend on another Index — only
|
|
170
|
+
one **owned by the same Repository**, and never in a cycle of Index edges
|
|
171
|
+
(`ARCHITECTURE_VIOLATION_INDEX_DEP` otherwise). It is not a way to chain lookups:
|
|
172
|
+
reach for it only when a second parse of the Store would duplicate state the
|
|
173
|
+
first Index already holds (wairon's export tables over its scanned specs).
|
|
174
|
+
An Index that **scans** a tree of project roots may read the configuration of
|
|
175
|
+
the root it is walking, through that root's own binding — the same per-root
|
|
176
|
+
binding it already resolves that root's files through — and nothing else of
|
|
177
|
+
it (wairon's spec scan reads each root's `project.yaml` for the project graph).
|
|
178
|
+
- **Query** computes reads over its own Repository's Store: it depends **only on
|
|
179
|
+
its Store, a backend Adapter or pure logic**, and it lives only inside a
|
|
180
|
+
Repository (`UNOWNED_QUERY`).
|
|
181
|
+
- **Registry** is the write path to a Store; it may depend on its Store, Adapters
|
|
182
|
+
(e.g. a message-bus Adapter to publish), and pure logic for validation. It does
|
|
183
|
+
**not** depend on Indexes, and it drives no workflow.
|
|
184
|
+
- **Adapter** is a dependency-sink toward the system (its other side is external).
|
|
185
|
+
It may use pure logic, but never a Store or any other Orchestrator. Workflows,
|
|
186
|
+
read logic, Actors, Supervisors and the data blocks may depend on an Adapter to
|
|
187
|
+
reach an external system. **Cross-subsystem client Adapter** (the one
|
|
188
|
+
Adapter that depends *outward* on a Portal): a sibling subsystem is "potentially
|
|
189
|
+
external" — designed to be swappable for a network service — so a local client
|
|
190
|
+
Adapter is its only reachable surface, and it depends on the **remote subsystem's
|
|
191
|
+
Portal** (its front door), which dispatches inward. It still may not depend on the
|
|
192
|
+
remote subsystem's Orchestrators or Stores directly.
|
|
193
|
+
- The narrative `call` graph and the `dependsOn` graph must both be **DAGs** (no
|
|
194
|
+
cycles), and every narrative `call` must target a method on a declared dependency.
|
|
195
|
+
|
|
196
|
+
### The Orchestrator, precisely
|
|
197
|
+
|
|
198
|
+
An Orchestrator is **logic as a flowchart**: ordered steps, conditions, branches,
|
|
199
|
+
loops, parallel dispatch and calls, where a call to another Orchestrator nests its
|
|
200
|
+
flowchart, down to leaf Orchestrators of native logic. It holds its collaborators
|
|
201
|
+
(constructor-injected, or closed over by a module of functions — see
|
|
202
|
+
[language-bindings.md](language-bindings.md)) and **no domain state**: each
|
|
203
|
+
call's state lives on the stack, so a shared instance is concurrency-safe and its
|
|
204
|
+
instantiation lifecycle is irrelevant to correctness.
|
|
205
|
+
|
|
206
|
+
- **All logic is a flowchart.** It is an Orchestrator's, an Actor's or
|
|
207
|
+
Supervisor's own method over its own runtime state, or a type method for
|
|
208
|
+
arithmetic over one value's own fields (§5). A verdict, a translation, a derived
|
|
209
|
+
view and a multi-step workflow are all Orchestrators; what tells them apart is
|
|
210
|
+
what they may depend on. **Local control flow** (guard clauses, a Registry's
|
|
211
|
+
`exists?` check) is fine anywhere.
|
|
212
|
+
- **The dependency class** says what an Orchestrator may reach:
|
|
213
|
+
|
|
214
|
+
| `dependencyClass` | May depend on | Typical shapes |
|
|
215
|
+
|---|---|---|
|
|
216
|
+
| `pure` | pure Orchestrators only | a verdict over supplied facts, a format translation, a view or text derived from values it is handed |
|
|
217
|
+
| `read` | pure and read Orchestrators, Repositories, Indexes, Adapters — reading, never writing | a derivation that loads its own source, a lookup that gathers the facts for a verdict |
|
|
218
|
+
| unset (a workflow) | whatever the other rules allow | writes, sequences, transactions, reaching live Actors |
|
|
219
|
+
|
|
220
|
+
Choose the narrowest class that holds, and split to reach it: loading in the
|
|
221
|
+
workflow and deriving in pure logic makes the derivation replayable and
|
|
222
|
+
testable without fakes.
|
|
223
|
+
- **One or more cohesive methods.** An Orchestrator is the unit that holds one
|
|
224
|
+
set of collaborators. Its methods belong together when they serve one purpose
|
|
225
|
+
and share those collaborators; groups of methods that call no common component
|
|
226
|
+
are two Orchestrators in one.
|
|
227
|
+
- Dependencies are injected at construction; method arguments carry only the
|
|
228
|
+
**workflow input**. For "same workflow over many repositories," prefer one
|
|
229
|
+
instance per concrete dependency behind a shared interface; use dynamic dispatch
|
|
230
|
+
only when the choice is genuinely per-call.
|
|
231
|
+
|
|
232
|
+
### Self-initiated and long-running processes
|
|
233
|
+
|
|
234
|
+
There is **no separate "Process/Daemon" block** — owning one live thing *is* the
|
|
235
|
+
**Actor's** defining job. An Actor holds the runtime state of one live thing — a
|
|
236
|
+
session, a connection, a timer, or an entity instance — and handles its events one
|
|
237
|
+
at a time, so that state has a single writer. Its **methods are full
|
|
238
|
+
flowcharts**: an Actor method may call Repository facades, Indexes, Adapters and
|
|
239
|
+
Orchestrators itself. A part moves into an Orchestrator only when something else
|
|
240
|
+
also triggers it, or when it is too big to read in place. An Actor never passes
|
|
241
|
+
itself to an Orchestrator; it passes values from its state and applies the result.
|
|
242
|
+
The **Supervisor** owns the *set* of Actors and their lifecycle (find-or-start by
|
|
243
|
+
id, stop, restart with backoff, unload idle instances) and may supervise other
|
|
244
|
+
Supervisors, so a runtime is a **tree** under one root Supervisor. The state that
|
|
245
|
+
bookkeeping needs is the Supervisor's own supervision state — a Store or Registry
|
|
246
|
+
it owns — and the live handles callers look Actors up by are a Registry it
|
|
247
|
+
maintains.
|
|
248
|
+
|
|
249
|
+
| Need | Modeled as |
|
|
250
|
+
|---|---|
|
|
251
|
+
| cron / scheduler | an Actor owning the timer; each tick is its own method, calling the Orchestrator that does the work |
|
|
252
|
+
| batch job | an Actor owning the iteration, handling each item itself or through an Orchestrator |
|
|
253
|
+
| stream / websocket consumer | an Actor owning the read-loop + an Adapter owning the connection |
|
|
254
|
+
| message-bus consumer | an Observer subscribing via a bus Adapter, or an Actor owning a poll-loop |
|
|
255
|
+
| daemon | an Actor owning a long-running loop |
|
|
256
|
+
|
|
257
|
+
**Entity Actors.** An Actor may be the live instance of an entity — an auction
|
|
258
|
+
lot, a match, a device twin. The entity type stays data with pure methods; the
|
|
259
|
+
Actor holds one instance's working state and handles its messages; the **Store
|
|
260
|
+
stays authoritative**, loaded when the Actor starts and written through the
|
|
261
|
+
Repository. Use an entity Actor when live in-memory interaction is the point; use
|
|
262
|
+
persisted status plus event-driven workflows when steps are minutes or days
|
|
263
|
+
apart. A live entity Actor is the **only writer of its aggregate** in its
|
|
264
|
+
process: every other writer messages it through its Supervisor (a cluster needs a
|
|
265
|
+
lease or sharded routing to keep one writer).
|
|
266
|
+
|
|
267
|
+
**Large decisions: the Decider shape.** When an Actor's handling of a message
|
|
268
|
+
holds real decision logic, split it: *decide* — a pure Orchestrator, given the
|
|
269
|
+
state and the message, returns a decision; *persist* — the Actor's method writes
|
|
270
|
+
it in the transaction it owns (§10); *apply* — only after the commit, the Actor
|
|
271
|
+
updates its own state; *act* — it notifies or emits through its collaborators.
|
|
272
|
+
The decision logic is then replayable and testable without the Actor.
|
|
273
|
+
|
|
274
|
+
**Timers.**
|
|
275
|
+
|
|
276
|
+
| Timer | Modeled as |
|
|
277
|
+
|---|---|
|
|
278
|
+
| per-instance and loss-safe (a heartbeat, an idle timeout) | state of the Actor that owns the instance |
|
|
279
|
+
| a deadline on an aggregate (a lot's end, an order's payment due) | a **field of the aggregate** (`lot.endsAt`), read by its Index; a sweep Actor handles what falls due |
|
|
280
|
+
| a timer that spans several aggregates | a schedule aggregate with its own Repository, fired by an Actor |
|
|
281
|
+
| a process-wide schedule (a backup every minute) | its own Actor |
|
|
282
|
+
| restart backoff, passivation | the Supervisor |
|
|
283
|
+
| an external scheduler | an Adapter |
|
|
284
|
+
|
|
285
|
+
A deadline kept as a field survives a restart with nothing preloaded: the sweep
|
|
286
|
+
finds whatever fell due while the process was down.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## 4. External I/O and emission — the Adapter
|
|
291
|
+
|
|
292
|
+
The **Adapter** is the single boundary to the outside world, in two shapes:
|
|
293
|
+
1. **Behind a Store** — a DB/FS Adapter the Store translates domain calls into
|
|
294
|
+
(`store.get` → `adapter.query(...)` → deserialize → entity).
|
|
295
|
+
2. **Standalone outbound** — an Orchestrator, Actor or Registry calling an
|
|
296
|
+
HTTP/gRPC/email/message-bus Adapter directly for an external effect. An
|
|
297
|
+
external tool — a linter run as a process, a policy engine behind an API — has
|
|
298
|
+
the same shape: an Adapter runs it, and an Orchestrator interprets its output.
|
|
299
|
+
|
|
300
|
+
**Inbound vs outbound:** the **Portal** is *our* front door (others call us); the
|
|
301
|
+
**Adapter** is *our* client to external systems (we call/connect out, including
|
|
302
|
+
publishing). They are different roles, not duplicates.
|
|
303
|
+
|
|
304
|
+
**Emission has no special block, and is optional.** Events are a pattern for
|
|
305
|
+
**decoupled reactions** — used when a producer should not know its consumers.
|
|
306
|
+
Realizations, smallest-first:
|
|
307
|
+
- *No bus (in-process):* "emit" is a **fire-and-forget** call — spawn a task/thread
|
|
308
|
+
that invokes the handler/Observer; the producer does not await it.
|
|
309
|
+
- *Message bus:* emit by calling a **message-bus Adapter** (`publish(topic, event)`)
|
|
310
|
+
— a normal `dependsOn → Adapter` edge; an Observer subscribes via a bus Adapter.
|
|
311
|
+
- *Simplest systems* may skip events entirely and call the next step directly.
|
|
312
|
+
|
|
313
|
+
There is no "EventPublisher" block in any case. Event topology is declared, not
|
|
314
|
+
inferred: a component lists the topics it `emits` and `subscribesTo` (L2
|
|
315
|
+
fields), and a MessageBus public interface's endpoints carry a
|
|
316
|
+
`direction: publish | subscribe`. The validator pairs the two by topic —
|
|
317
|
+
an emitted topic nobody consumes is `UNCONSUMED_TOPIC`, a subscription with no
|
|
318
|
+
source is `UNSOURCED_SUBSCRIPTION` (topics with external ends are acknowledged
|
|
319
|
+
via `lint.allow`). Trees that use no events see neither check.
|
|
320
|
+
|
|
321
|
+
**Ports + implementations (the Carrier rule).** When a capability has multiple
|
|
322
|
+
external providers, define an **interface (a port)** and one Adapter per provider:
|
|
323
|
+
`Carrier` interface ← `DhlAdapter`, `UpsAdapter`. Callers depend on the `Carrier`
|
|
324
|
+
interface; the concrete Adapter is injected at bootstrap. This is composition +
|
|
325
|
+
polymorphism via interfaces — **not** inheritance.
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
329
|
+
## 5. Object modeling: where behavior lives
|
|
330
|
+
|
|
331
|
+
Entities are **passive state**; behavior belongs to the object that can perform it.
|
|
332
|
+
The test: **can the object do this autonomously, using only its own state?**
|
|
333
|
+
|
|
334
|
+
- **Yes → an intrinsic method on the entity.** `order.total()`, `order.is_valid()`,
|
|
335
|
+
`dog.bark()` — pure logic over the entity's own fields (computed values,
|
|
336
|
+
invariants, self-contained state transitions).
|
|
337
|
+
- **No (it needs an external actor or system) → a method on the acting *block*,
|
|
338
|
+
taking the entity as an argument.** An order cannot ship itself → a `Carrier`
|
|
339
|
+
(interface) with `DhlAdapter.ship(order)`. A dog cannot take itself out → a
|
|
340
|
+
`Caretaker` (an Orchestrator) with `take_out(dog)`.
|
|
341
|
+
|
|
342
|
+
This is the rich-vs-anemic balance, and it maps onto the blocks: **the Store holds
|
|
343
|
+
the entity; Registries/Orchestrators/Actors/Adapters act *on* it.** It keeps
|
|
344
|
+
domain logic out of passive data and prevents "god entities" that secretly
|
|
345
|
+
orchestrate. Behavior that belongs to **one live instance**, message by message,
|
|
346
|
+
lives on an entity Actor (§3), which still keeps the entity itself passive.
|
|
347
|
+
|
|
348
|
+
**Writes follow the aggregate.** An owner's value objects (a customer's address)
|
|
349
|
+
are saved with it in one Store call. A separate aggregate (the customer's
|
|
350
|
+
credentials) is written by the workflow, in one transaction when both must land
|
|
351
|
+
together (§10), with the ids generated in the workflow and the dependent
|
|
352
|
+
referencing its owner.
|
|
353
|
+
|
|
354
|
+
**Composition over inheritance.** The model has no class hierarchies. Variation is
|
|
355
|
+
expressed by **interfaces (ports) with multiple implementations** (the Carrier
|
|
356
|
+
example) and by **composition** (a pattern owns blocks). Inheritance is avoided;
|
|
357
|
+
"is-a" relationships that tempt a hierarchy are almost always "implements-an-
|
|
358
|
+
interface" or "is-composed-of."
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## 6. Entities and types
|
|
363
|
+
|
|
364
|
+
Entities and value objects are **first-class type specs** (not one of the ten
|
|
365
|
+
blocks — they are the *data* the blocks operate on). Each is defined **exactly
|
|
366
|
+
once**, scoped by ownership:
|
|
367
|
+
|
|
368
|
+
- **Shared value objects** with no single owner (Money, Address, Email) → the
|
|
369
|
+
**system-level `types/`** (sibling to subsystems, under L0).
|
|
370
|
+
- **Entities / aggregates** (Order, Customer) → defined in the **subsystem that
|
|
371
|
+
owns their lifecycle** (the bounded context whose Store is authoritative):
|
|
372
|
+
`<subsystem>/types/`.
|
|
373
|
+
|
|
374
|
+
A type is **never** redefined in multiple places and is **not** a sibling to all
|
|
375
|
+
subsystems. When other subsystems process an Order, they **reference the owner's
|
|
376
|
+
contract** (its L1 public interface) or hold a **local projection/DTO** (an
|
|
377
|
+
anti-corruption view) — they do not share or redefine the owning aggregate. An L3
|
|
378
|
+
interface references types by id, so signatures stay structured (which also makes
|
|
379
|
+
data-model/ER diagrams derivable — see §14).
|
|
380
|
+
|
|
381
|
+
### Type expressions: one neutral grammar
|
|
382
|
+
|
|
383
|
+
Every structured type position — a param's `type`, a method's `returns`, a
|
|
384
|
+
field's `type`, a type method's and a signature type's params and returns — is
|
|
385
|
+
written in one language-neutral grammar, so a contract means the same thing to
|
|
386
|
+
every implementation language:
|
|
387
|
+
|
|
388
|
+
| Form | Meaning |
|
|
389
|
+
|---|---|
|
|
390
|
+
| `string`, `int`, `float`, `bool`, `bytes` | the scalar primitives (`int` and `float` are distinct; width is an L4 concern) |
|
|
391
|
+
| `date`, `datetime`, `duration` | a calendar day, an instant, an elapsed time |
|
|
392
|
+
| `void`, `any` | no value (a whole returns only; `()` is another spelling), and an unconstrained value |
|
|
393
|
+
| `list<T>`, `set<T>`, `map<K, V>` | ordered, unique, keyed; a map key is `string`, `int` or an enum |
|
|
394
|
+
| `T?` | T, or no value |
|
|
395
|
+
| `A \| B` | exactly one of the named types (a primitive or collection never joins a union) |
|
|
396
|
+
| `async T` | completes later with T — at the top of a returns only (`async void`) |
|
|
397
|
+
| `result<T, E>` | completes with T or fails with E — a whole returns only, or under its `async`; T may be `void` |
|
|
398
|
+
| `Invoice`, `billing::invoice`, `Page<T>` | a named type: an entity, value-object, enum or signature type |
|
|
399
|
+
|
|
400
|
+
"No value" (`T?`) belongs to a type; "may be left out" belongs to a position
|
|
401
|
+
and stays the `optional` flag on a param or field. Both together mean "may be
|
|
402
|
+
left out, and may be explicitly none".
|
|
403
|
+
|
|
404
|
+
An **enum** is a type of its own kind: a closed, ordered set of named values
|
|
405
|
+
(`values: [{ name, description? }]`), optionally with pure methods. A field
|
|
406
|
+
typed by an enum is a value domain, not a relation, so the ER diagram draws no
|
|
407
|
+
edge to it, and OpenAPI renders it as a string `enum`.
|
|
408
|
+
|
|
409
|
+
A **named scalar** is a value-object that declares `holds: <primitive>` in
|
|
410
|
+
place of fields: one value under a name (`order_id` holds `string`), a newtype
|
|
411
|
+
or type alias in every language (`type OrderId = string`). It holds one of
|
|
412
|
+
`string`, `int`, `float`, `bool`, `bytes`, `date`, `datetime` or `duration` —
|
|
413
|
+
not a union, not a named type, not `any` — and carries no fields beside it
|
|
414
|
+
(`NAMED_SCALAR_MEMBERS`). It is how a value that needs its own meaning gets a
|
|
415
|
+
name instead of staying a bare `string`, and how a choice that mixed in a
|
|
416
|
+
primitive (`PackSelection | string`) becomes a union of named types
|
|
417
|
+
(`PackSelection | PackPath`). Like an enum it is a value domain: the ER diagram
|
|
418
|
+
draws it compactly with no edge to it, OpenAPI renders it as its primitive's
|
|
419
|
+
schema under its name, and type-shape conformance compares it with the code's
|
|
420
|
+
alias through the language's dialect (`TYPE_HOLDS_MISMATCH`).
|
|
421
|
+
|
|
422
|
+
`result<T, E>` is the success-or-failure type the mainstream languages with
|
|
423
|
+
typed failures share (Rust's and Swift's `Result`, Kotlin's `Result`, F#'s
|
|
424
|
+
`Result`). In a language without typed failures it is written as its success
|
|
425
|
+
type: in TypeScript the function returns T and throws E, so code conformance
|
|
426
|
+
compares the annotation with T and never with E.
|
|
427
|
+
|
|
428
|
+
TypeScript spellings (`string[]`, `boolean`, `T | null`, `Promise<T>`,
|
|
429
|
+
`Record<K, V>`) and Rust's `Result<T, E>` and `()` are accepted as input and
|
|
430
|
+
stored in the canonical spelling; the write answers with each respelling.
|
|
431
|
+
`number` is not accepted ("int or float?"). Inline object shapes, inline
|
|
432
|
+
function types, string-literal unions, intersections, utility types and unions
|
|
433
|
+
mixing in a primitive are not in the grammar: each has a named replacement (a
|
|
434
|
+
value-object, a signature type, an enum). The writer refuses each of them; a
|
|
435
|
+
tree that already holds one still loads and stays lockable, with a warning per
|
|
436
|
+
position, and `wairon doctor --fix` rewrites every alias and proposes an `int`
|
|
437
|
+
or an enum where it can. How each form is written in a given language is in
|
|
438
|
+
[Language bindings](language-bindings.md#type-mapping).
|
|
439
|
+
|
|
440
|
+
---
|
|
441
|
+
|
|
442
|
+
## 7. Patterns (named compositions)
|
|
443
|
+
|
|
444
|
+
A **pattern** is a *named, rule-bound composition of building blocks, exposed as
|
|
445
|
+
one component (a facade)*. A pattern owns **only building blocks** — never another
|
|
446
|
+
pattern (§9).
|
|
447
|
+
|
|
448
|
+
### Repository
|
|
449
|
+
> The data-access-layer component for **one aggregate**. It **owns** exactly one
|
|
450
|
+
> **Store** — the aggregate's entity and everything that must stay consistent with
|
|
451
|
+
> it, across as many tables as that takes — and the faces over it: a **Registry**
|
|
452
|
+
> (write face), one-or-more **Indexes** (read faces), any **Queries** (computed
|
|
453
|
+
> reads), and optionally one **Adapter** (the backend). It contains **no logic of
|
|
454
|
+
> its own**. The Store and Adapter are **private behind** the Registry, Indexes and
|
|
455
|
+
> Queries. Its facade **forwards 1:1** to the Registry (writes) and to the Indexes
|
|
456
|
+
> and Queries (reads); **consumers depend on the Repository facade only** — never
|
|
457
|
+
> the inner blocks.
|
|
458
|
+
|
|
459
|
+
The Registry's write is `validate → store.write (→ optionally publish)`; it does
|
|
460
|
+
**not** touch the Indexes. Indexes are **projections over the Store** that share its
|
|
461
|
+
per-entry references — a value-content update is seen through the shared reference
|
|
462
|
+
with no index change; only create/delete, or an update that changes an *indexed
|
|
463
|
+
field*, propagates structurally from the Store to its Indexes. Write-time
|
|
464
|
+
constraints (uniqueness, validation) are checked against the **Store** (the
|
|
465
|
+
authoritative source) with pure validation logic — not via an Index.
|
|
466
|
+
|
|
467
|
+
**Index or Query.** An Index shares the Store's references, so it is never stale.
|
|
468
|
+
A read the backend computes on every call — an aggregate, a ranking, a report the
|
|
469
|
+
database assembles — is not a projection: it is a **Query** member, depending on
|
|
470
|
+
its Store or the backend Adapter (and pure logic). A custom computed query is a
|
|
471
|
+
Query, never an Adapter-backed Index method, and never a consumer→Adapter shortcut.
|
|
472
|
+
|
|
473
|
+
**Reads across aggregates.** A Repository stays a self-contained unit: a Query
|
|
474
|
+
never depends on another Repository's facade (two such Queries would also form a
|
|
475
|
+
cycle). A join falls into one of three cases:
|
|
476
|
+
- **within one aggregate** — a Store or Query method whose join is its implementation;
|
|
477
|
+
- **across aggregates** with small data or in different databases — a **read
|
|
478
|
+
Orchestrator** joining the facades' results;
|
|
479
|
+
- **across aggregates in one database, as one statement** — a **read-model
|
|
480
|
+
Repository** whose Query runs the join through the shared database Adapter.
|
|
481
|
+
Declaring which aggregates a read model reads is not modelled yet; name them in
|
|
482
|
+
its description.
|
|
483
|
+
|
|
484
|
+
**Mapping an ORM.** An object-relational mapper fits the model: the database
|
|
485
|
+
context (Entity Framework's `DbContext`, for one) is the database **Adapter** —
|
|
486
|
+
the connection, change tracking and the transaction context §10 has it issue;
|
|
487
|
+
entity mappings, navigation properties and `Include` joins implement a **Store**;
|
|
488
|
+
cross-aggregate projections and view-mapped keyless entities are read-model
|
|
489
|
+
**Queries**. The one habit the model pushes against is querying the context
|
|
490
|
+
straight from a workflow.
|
|
491
|
+
|
|
492
|
+
**When the full pattern is overkill — the two sanctioned shapes for held state.**
|
|
493
|
+
The Repository is the RECOMMENDED shape wherever read and write consumers diverge
|
|
494
|
+
or write-time constraints exist. For genuinely simple held state, a **deliberately
|
|
495
|
+
standalone Store** is the sanctioned lightweight form: the state stays visible as a
|
|
496
|
+
component, reachable from the workflow layer (a workflow Orchestrator or an Actor),
|
|
497
|
+
acknowledged with a `lint.allow` reason on the `UNOWNED_STORE` warning — and it
|
|
498
|
+
declares its `durability` like every Store (`durable` | `read-through` |
|
|
499
|
+
`ram-projection` | `cache`). The durability axis is **orthogonal** to the shape:
|
|
500
|
+
an in-memory Repository (`ram-projection` Store inside) and a persisted bare Store
|
|
501
|
+
(`read-through` file backing) are both legal quadrants. What is NEVER sanctioned is
|
|
502
|
+
the third path — folding held state into an Orchestrator's fields; hidden state
|
|
503
|
+
is invisible to every rule in this standard. And a standalone **Registry** is not a
|
|
504
|
+
shape at all: a Registry is the write path to a Store (`REGISTRY_WITHOUT_STORE`) —
|
|
505
|
+
a component that itself holds persisted state is a *Store*, whatever its file I/O
|
|
506
|
+
looks like. A **Query** has no standalone shape either: it lives only inside the
|
|
507
|
+
Repository whose Store it reads (`UNOWNED_QUERY`).
|
|
508
|
+
|
|
509
|
+
### gateway: a Portal variant
|
|
510
|
+
> A gateway is a **Portal** with the built-in `gateway` variant: a Portal that
|
|
511
|
+
> **authenticates, authorizes, validates or rate-limits before it dispatches**, by
|
|
512
|
+
> calling that logic, and declares its inbound authentication in `auth`. The
|
|
513
|
+
> checks are Orchestrators it depends on — typically read logic that gathers the
|
|
514
|
+
> caller's grants and pure logic that rules on them — never members it owns. A
|
|
515
|
+
> Portal with no ingress checks needs no variant.
|
|
516
|
+
|
|
517
|
+
The variant carries the implementation guidance (§8). The Gateway *pattern*
|
|
518
|
+
(`Portal + ingress Orchestrator + interceptor Specialists`) is retired
|
|
519
|
+
(`STEREOTYPE_RETIRED`); §8 lists its migration.
|
|
520
|
+
|
|
521
|
+
### The facade rule (mechanically enforced: `FACADE_FORWARDING`)
|
|
522
|
+
A pattern's facade does **pure 1:1 forwarding with no logic**: **every facade
|
|
523
|
+
method's authored narrative is exactly one `call` step targeting an owned
|
|
524
|
+
member.** More than one step, a `local` step, or a call that leaves the pattern
|
|
525
|
+
means the facade contains logic — a violation. The validator enforces this on
|
|
526
|
+
Repository facades as the `FACADE_FORWARDING` warning
|
|
527
|
+
(lint.allow-suppressible for deliberate exceptions); methods without an
|
|
528
|
+
authored narrative are governed by the detail dial, not this rule.
|
|
529
|
+
|
|
530
|
+
### No wildcard block
|
|
531
|
+
The model has no residual block for what fits nowhere else. A need that seems to
|
|
532
|
+
fit no block is a decomposition not yet made: held or derived state → a Store,
|
|
533
|
+
Index or Repository; behavior over a value's own fields → a type method; external
|
|
534
|
+
I/O → an Adapter; logic → an Orchestrator with the narrowest `dependencyClass`
|
|
535
|
+
that holds; one live thing → an Actor.
|
|
536
|
+
|
|
537
|
+
---
|
|
538
|
+
|
|
539
|
+
## 8. Roles (naming vocabulary)
|
|
540
|
+
|
|
541
|
+
Below patterns are **roles** — conventional names for Orchestrators (and some
|
|
542
|
+
Indexes) that share a block's rules but carry a documented name + recommended
|
|
543
|
+
interface. They are vocabulary, **not** distinct types.
|
|
544
|
+
|
|
545
|
+
| Role | Recommended interface | Underlying block |
|
|
546
|
+
|---|---|---|
|
|
547
|
+
| Router | `route(input) → destination` | Index (flat key→target) or a pure Orchestrator (rule-based) |
|
|
548
|
+
| Validator | `validate(x) → Result` | a pure Orchestrator |
|
|
549
|
+
| Scanner | `scan(scope) → findings` | a pure Orchestrator handed the scope, or a read Orchestrator that loads it |
|
|
550
|
+
| Mapper / Compiler / Evaluator | one transform method | a pure Orchestrator |
|
|
551
|
+
|
|
552
|
+
### The built-in variants
|
|
553
|
+
|
|
554
|
+
One step above roles sit **variants** — recurring shapes with a machine-recognized
|
|
555
|
+
identity (`variant:` on the component, resolved against the variant registry)
|
|
556
|
+
whose guidance travels into every agent brief. wairon ships a **built-in layer**.
|
|
557
|
+
The registry loads it first, then the global variants (`WAIRON_VARIANTS_DIR`, else
|
|
558
|
+
`~/.wairon/variants`), then the project's `.wai/variants/`; a later layer
|
|
559
|
+
**overrides a variant with the same id**, so a project can reword the built-in
|
|
560
|
+
guidance for its own team. A component's stereotype must equal its variant's
|
|
561
|
+
`base` (`VARIANT_BASE_MISMATCH`).
|
|
562
|
+
|
|
563
|
+
Variants carry **identity and guidance, not rules**. What an Orchestrator may
|
|
564
|
+
depend on is its `dependencyClass` field, which the validator enforces; one shape
|
|
565
|
+
can sit in either class, which is why the class is a field and not a variant.
|
|
566
|
+
|
|
567
|
+
| Variant | Base | Class | Shape | Discipline (guidance-enforced) |
|
|
568
|
+
|---|---|---|---|---|
|
|
569
|
+
| `arbiter` | Orchestrator | `pure` | subject + supplied world → deterministic verdict + reasons | NO I/O, no state deps — the caller gathers the world (companion shape: read logic or a workflow gathers, the arbiter rules); no clock/randomness; `idempotent` where it holds |
|
|
570
|
+
| `projector` | Orchestrator | `pure` when handed its source, `read` when it loads it | source model → self-contained derived view (snapshot, graph, artifact, digest) | ≤1 read facade or parameters-only; recomputed per call, owns nothing, writes nothing. NOT an Index: an Index is a maintained read model over an owned Store (or, exceptionally, over another Index of the same Repository) |
|
|
571
|
+
| `composer` | Orchestrator | `pure` when handed its values, `read` when it loads them | templates + values → authored text/file map | returns content, never writes or executes it; degrades gracefully on missing optional inputs |
|
|
572
|
+
| `codec` | Orchestrator | `pure` | format ↔ format, bidirectional | pure whole-value translation; inbound half validates + safety-checks; both directions in one component so the round-trip stays testable |
|
|
573
|
+
| `gateway` | Portal | — | a Portal that authenticates, authorizes, validates or rate-limits before dispatching | calls that logic before it dispatches; declares its inbound auth in `auth`; writes still route through Orchestrators |
|
|
574
|
+
|
|
575
|
+
A variant is **promoted to a first-class stereotype** only when independent
|
|
576
|
+
projects/packs keep re-registering it, or when its edge rules exceed what guidance
|
|
577
|
+
can express.
|
|
578
|
+
|
|
579
|
+
### Retired stereotypes
|
|
580
|
+
|
|
581
|
+
`Specialist` and `Gateway` are retired. A component typed with either is
|
|
582
|
+
`STEREOTYPE_RETIRED` (error) until it is migrated; meanwhile the rules that judge a
|
|
583
|
+
stereotype's shape skip it, so it reports once.
|
|
584
|
+
|
|
585
|
+
| Retired | Becomes | Migration |
|
|
586
|
+
|---|---|---|
|
|
587
|
+
| `Specialist` | an Orchestrator with the `dependencyClass` its dependencies give it | `wairon doctor --fix` retypes it with that class, and rebases each Specialist-based variant onto Orchestrator |
|
|
588
|
+
| `Gateway` | the Portal it owned, with the `gateway` variant | by hand: (1) the owned Portal becomes the front door, with `variant: gateway`; (2) the Gateway's other members become that Portal's dependencies; (3) its consumers depend on the Portal; (4) delete the Gateway spec — `sdd_rename_component` can then give the Portal the Gateway's id |
|
|
589
|
+
|
|
590
|
+
**Router vs Portal vs Facade:** a **Router** selects a destination from *dynamic
|
|
591
|
+
input values*; a **Portal** is the *transport* boundary that *uses* a Router/Index
|
|
592
|
+
to dispatch; a **Facade** does *no* routing (the caller already chose the method).
|
|
593
|
+
|
|
594
|
+
---
|
|
595
|
+
|
|
596
|
+
## 9. Relationships, composition, and layering
|
|
597
|
+
|
|
598
|
+
- **`owns` (composition):** a pattern owns its member blocks — **exactly one hop**;
|
|
599
|
+
patterns never own patterns, blocks own nothing. This makes depth finite (no
|
|
600
|
+
"L2.5").
|
|
601
|
+
- **`dependsOn` (collaboration):** any component uses others.
|
|
602
|
+
- **Visibility rule:** a component may depend on (a) blocks within its own group,
|
|
603
|
+
(b) the *facade* of any other group, or (c) any standalone block — **never** a
|
|
604
|
+
block private to another group.
|
|
605
|
+
- **"Leaf" is graph-specific:** an *ownership-leaf* owns no sub-components (every
|
|
606
|
+
block); a *dependency-sink* depends on nothing (typically a Store or Adapter). A
|
|
607
|
+
read Orchestrator is an ownership-leaf that may still depend on a Repository —
|
|
608
|
+
so it is not a dependency-sink. The two are orthogonal.
|
|
609
|
+
|
|
610
|
+
**Layering inside a subsystem:**
|
|
611
|
+
```
|
|
612
|
+
Ingress: Portal (with the gateway variant when it authenticates, authorizes, validates or rate-limits first)
|
|
613
|
+
Workflow: Orchestrators: workflows, over read and pure logic
|
|
614
|
+
Data: Repository = Store (one aggregate) + Registry (write+constraints) + Indexes/Queries (read) + Adapter (private)
|
|
615
|
+
Process: a Supervisor tree + Actors (sessions, connections, timers, live entity instances)
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
Workflow Orchestrators **use** Repositories; Repositories never contain a Portal or
|
|
619
|
+
an Orchestrator. **When you are tempted to nest a pattern inside a
|
|
620
|
+
component, promote it to a subsystem (L1)** — composition of *patterns* is an L1
|
|
621
|
+
concern. A **Saga** is therefore an L1-level arrangement (an Orchestrator + an
|
|
622
|
+
Observer + a sibling Repository for its persisted progress), not an L2 pattern.
|
|
623
|
+
|
|
624
|
+
### Crossing a subsystem boundary: published surfaces and their consumers
|
|
625
|
+
A component in another subsystem may depend only on what that subsystem
|
|
626
|
+
**publishes** in its `publicInterfaces` (`CROSS_SUBSYSTEM_PRIVATE_ACCESS`
|
|
627
|
+
otherwise), and the hop enters through a Portal (`CROSS_SUBSYSTEM_TARGET_NON_PORTAL`).
|
|
628
|
+
An entry may narrow who may use it with **`consumers`**: a list of subsystem ids.
|
|
629
|
+
Without it, any subsystem may depend on the entry; with it, only those listed
|
|
630
|
+
may — a dependency from any other subsystem is `CROSS_SUBSYSTEM_UNLISTED_CONSUMER`
|
|
631
|
+
(an error). When several entries publish the same component, each naming
|
|
632
|
+
consumers, the union of their lists is the whole set.
|
|
633
|
+
|
|
634
|
+
### Listeners mount Portals
|
|
635
|
+
A Portal's routes are its methods' endpoint bindings; the **listener** that
|
|
636
|
+
hands the Portal its requests declares that with **`mounts`** on its own L2
|
|
637
|
+
spec — each mount names the `portal`, the path `prefixes` it routes there, and
|
|
638
|
+
optionally `via`, the router entry the Portal's file exports for it. An HTTP
|
|
639
|
+
Portal that no listener mounts, and that declares no `mounts` itself (which
|
|
640
|
+
marks it as a listener, even with `mounts: []`), is `UNMOUNTED_PORTAL`: nothing
|
|
641
|
+
would ever reach it. `mounts: []` is honest only on a Portal that IS the whole
|
|
642
|
+
HTTP server and serves just its own routes; a service with several HTTP
|
|
643
|
+
Portals has one listener that mounts each of them. A prefix matches whole path segments (`/web` covers
|
|
644
|
+
`/web/admin`, not `/webhooks`). Only HTTP endpoints are judged.
|
|
645
|
+
|
|
646
|
+
### Forwarding a contract: `signatureFrom`
|
|
647
|
+
A method that takes its params and returns from somewhere else names the source
|
|
648
|
+
in **`signatureFrom`** instead of restating them: a signature type
|
|
649
|
+
(`billing.change_listener`, or `alias::name` across projects), or a contract
|
|
650
|
+
method `component.method` that its component reaches along a `dependsOn` or
|
|
651
|
+
`owns` edge — the shape of a facade or a forwarding Portal. The loader resolves
|
|
652
|
+
the source on every read, so the forwarding method can never drift from it.
|
|
653
|
+
|
|
654
|
+
---
|
|
655
|
+
|
|
656
|
+
## 10. Implementation variants (L4) — language-neutral rule
|
|
657
|
+
|
|
658
|
+
Structure (L2/L3) is *what it is*; the concurrency strategy (L4) is *how it is
|
|
659
|
+
realized*. **Concurrency is only a concern when shared mutable state is actually
|
|
660
|
+
accessed concurrently** — a single-threaded or simple system uses plain ownership
|
|
661
|
+
and skips this section entirely; do not add concurrency machinery a system does not
|
|
662
|
+
need. When it *is* needed, the rule, stated neutrally:
|
|
663
|
+
|
|
664
|
+
| Updating… | Strategy |
|
|
665
|
+
|---|---|
|
|
666
|
+
| one value, simple read-modify-write (counter, flag, single reference) | a single **atomic operation** — wait-free, ordered, cheaper than a lock |
|
|
667
|
+
| one location, compound update with no atomic equivalent (rare) | a compare-and-set retry (race-free; avoid over large/variable values) |
|
|
668
|
+
| multiple locations that must stay mutually consistent (a map; or two fields) | **serialize writes (in order) + wait-free snapshot reads** (the default) |
|
|
669
|
+
|
|
670
|
+
**Default = wait-free reads / in-order serialized writes:** readers read a snapshot
|
|
671
|
+
reference without blocking; writers are serialized so they apply in order; on
|
|
672
|
+
commit the snapshot reference is swapped for all readers. Values are held by
|
|
673
|
+
reference so the snapshot swap is pointer-only (zero-copy).
|
|
674
|
+
|
|
675
|
+
The concrete primitives per language (atomics, swappable references, sharded maps,
|
|
676
|
+
persistent structures, transactional backends) live in
|
|
677
|
+
[language-bindings.md](language-bindings.md). The standard itself prescribes only
|
|
678
|
+
the *strategy*, so it remains language-agnostic.
|
|
679
|
+
|
|
680
|
+
**Exceptions to the default:** write-heavy large collections → sharded locks or a
|
|
681
|
+
persistent structure; cross-store atomic transactions → a transactional backend via
|
|
682
|
+
the Adapter (see the unit-of-work rule below); single-writer (Actor) state →
|
|
683
|
+
wait-free reads, no write lock; non-shared / write-once state → plain ownership.
|
|
684
|
+
|
|
685
|
+
### Transactions & the unit of work
|
|
686
|
+
|
|
687
|
+
A transaction is a **value, not a place**. The doctrine, in five rules:
|
|
688
|
+
|
|
689
|
+
1. **The backend Adapter issues the transaction context.** The Adapter over the
|
|
690
|
+
transactional technology (declared via `technologies` on its L4) exposes
|
|
691
|
+
begin/commit/rollback as ordinary contract methods; `begin` returns an opaque
|
|
692
|
+
transaction-context value. No other block ever creates one.
|
|
693
|
+
2. **The method that owns the workflow owns the scope** — an Orchestrator's
|
|
694
|
+
method, or an Actor's own method when the Actor runs the workflow. Begin,
|
|
695
|
+
commit, and rollback are narrative steps of that method — begin, then a
|
|
696
|
+
`try` region whose calls carry the context, commit as the body's last step,
|
|
697
|
+
rollback in the `catch` handler. The transaction boundary is thereby VISIBLE
|
|
698
|
+
in the L5 narrative, reviewable like any other flow.
|
|
699
|
+
3. **The context travels as an explicit method argument.** Repository facades
|
|
700
|
+
(and their inner Registry/Store) MAY accept an optional transaction-context
|
|
701
|
+
parameter on write-face methods and pass it through to the backend Adapter.
|
|
702
|
+
Never store the context in component state — a held transaction is hidden
|
|
703
|
+
state and a concurrency hazard in one.
|
|
704
|
+
4. **In-memory state changes only after commit.** An Actor applies a decision to
|
|
705
|
+
its own state after the commit step, never before: a rollback leaves nothing in
|
|
706
|
+
memory to undo, and no reader sees a state the Store does not hold.
|
|
707
|
+
5. **Guarantee tags stay honest.** A method declaring `transactional`/`atomic`
|
|
708
|
+
should either bind a transactional backend through its technology Adapter or
|
|
709
|
+
accept the context parameter. The validator checks claim↔declaration
|
|
710
|
+
consistency (a narrative step asserting a guarantee must call a method
|
|
711
|
+
declaring it) — whether the transaction actually holds is implementation
|
|
712
|
+
correctness, proven by tests, not by the gate.
|
|
713
|
+
|
|
714
|
+
**Publish-and-persist atomically = the outbox pattern.** When a workflow must
|
|
715
|
+
persist domain state AND emit an event without a gap, do not call the bus in the
|
|
716
|
+
same breath as the write: append an outbox entry in the SAME transaction as the
|
|
717
|
+
domain write, through a **sibling outbox Repository** (the outbox is an aggregate
|
|
718
|
+
of its own, so it has its own Store), and let an Actor (or Observer on the
|
|
719
|
+
backend's change feed) drain the outbox, publish through the bus Adapter, and mark
|
|
720
|
+
entries delivered. A relay that restarts republishes, so consumers drop duplicates
|
|
721
|
+
by event id. The Saga arrangement (§9) composes with this: the outbox is its
|
|
722
|
+
delivery half, and the saga's progress Repository its memory.
|
|
723
|
+
|
|
724
|
+
---
|
|
725
|
+
|
|
726
|
+
## 11. Zero-copy / reference semantics (language-neutral)
|
|
727
|
+
|
|
728
|
+
The **Store** owns the authoritative per-entry reference. **Indexes** hold the
|
|
729
|
+
**same** references (shared, not value-copied), keyed differently — so reads are
|
|
730
|
+
zero-copy and **value-content updates are transparent**: updating the entity behind
|
|
731
|
+
its shared reference is seen by the Store and every Index at once, with no index
|
|
732
|
+
change. Only **structural** changes — create, delete, or an update that changes an
|
|
733
|
+
indexed field — propagate from the Store to its Indexes (an internal change-
|
|
734
|
+
propagation mechanism, *not* the domain Observer block). The **Registry** writes
|
|
735
|
+
only the Store; it never updates Indexes. (Where a language can't share references
|
|
736
|
+
safely, the binding appendix gives the equivalent.)
|
|
737
|
+
|
|
738
|
+
A derived Index — the exceptional Index over another Index of the same
|
|
739
|
+
Repository (§3) — projects that Index's references rather than the Store's,
|
|
740
|
+
and is dropped and rebuilt with it, so the same holds for it.
|
|
741
|
+
|
|
742
|
+
A coherent Index defined this way is **never stale**. A deliberately **evicting /
|
|
743
|
+
TTL cache** is a different thing — it is for *external or expensive-to-compute*
|
|
744
|
+
data, not a projection of an in-process Store. An external cache service (e.g.
|
|
745
|
+
Redis) is a caching **Adapter**; an in-process memo/TTL cache is a **Store with
|
|
746
|
+
`durability: cache`** — evictable, loss-safe, hydration-exempt, and VISIBLE as
|
|
747
|
+
held state. It is never a memoizing Orchestrator (an Orchestrator holds only its
|
|
748
|
+
collaborators) and never a private field inside a logic component (hidden state).
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
## 12. Infrastructure (not building blocks)
|
|
753
|
+
|
|
754
|
+
- **Message broker** — external infra (like a database). *Our client to it is an
|
|
755
|
+
Adapter* (so publish/subscribe edges are visible in the graph — see §4).
|
|
756
|
+
- **Composition root / wiring (DI)** — the bootstrap that constructs the graph and
|
|
757
|
+
injects dependencies. Infrastructure, not a domain block.
|
|
758
|
+
- **Cross-cutting** (authz, logging/metrics/tracing, config): coarse checks run at
|
|
759
|
+
the ingress — a Portal with the `gateway` variant calls authentication,
|
|
760
|
+
authorization, validation and rate-limit logic before it dispatches — plus infra
|
|
761
|
+
facilities; fine-grained authz lives in Registries/Orchestrators. Config loaded
|
|
762
|
+
at boot is infra injected at the composition root.
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
## 13. Right-sizing the model
|
|
767
|
+
|
|
768
|
+
The standard upholds good structure at **any scale** — it must not force
|
|
769
|
+
microservice / distributed / concurrent complexity on a system that doesn't need it.
|
|
770
|
+
|
|
771
|
+
- **L1 is optional.** A small system (a restaurant reservation app, a single
|
|
772
|
+
webapp) can be **one subsystem ≈ the whole system** — the L1 layer collapses into
|
|
773
|
+
L0. Use multiple subsystems only when there are real bounded-context or deployment
|
|
774
|
+
boundaries.
|
|
775
|
+
- **Concurrency is optional** (§10) — only when shared mutable state is actually
|
|
776
|
+
accessed concurrently. A simple single-threaded app uses plain ownership.
|
|
777
|
+
- **Events are optional** (§4) — a pattern for decoupled reactions, not a mandate.
|
|
778
|
+
Without a message bus, in-process events are fire-and-forget calls; the simplest
|
|
779
|
+
systems call the next step directly.
|
|
780
|
+
- **Use the smallest set of blocks/patterns the system needs.** A tiny app might be
|
|
781
|
+
one Portal + a couple of Orchestrators + one Repository. Don't add blocks,
|
|
782
|
+
patterns, or layers a system doesn't require.
|
|
783
|
+
|
|
784
|
+
The *discipline* is the same everywhere (right block for the job, correct
|
|
785
|
+
dependencies, behavior on the right object); only the *amount* of structure scales.
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
## 14. Diagram generation
|
|
790
|
+
|
|
791
|
+
The spec tree is a typed graph, so it renders to diagrams with no extra
|
|
792
|
+
modeling. These surfaces are SHIPPED (interactive canvas, Mermaid, draw.io,
|
|
793
|
+
Excalidraw exports, and the flow modal):
|
|
794
|
+
- component + `dependsOn` → C4 / component diagrams
|
|
795
|
+
- `owns` tree → containment / module diagrams
|
|
796
|
+
- typed entities (§6) → data-model / ER diagrams
|
|
797
|
+
- L3 interfaces → contract views; L1 public interfaces → API maps
|
|
798
|
+
- **L5 narratives → sequence diagrams and flowcharts** (each `call` is a
|
|
799
|
+
message; `parallel` fans out, `detach` renders fire-and-forget)
|
|
800
|
+
|
|
801
|
+
Designs should stay faithful to the typed model so the diagrams stay free.
|
|
802
|
+
|
|
803
|
+
---
|
|
804
|
+
|
|
805
|
+
## 15. Worked example: a live auction
|
|
806
|
+
|
|
807
|
+
Sellers list lots. Bidders watch a lot live and place bids; a bid in the last
|
|
808
|
+
seconds extends the lot's end. An open lot closes at its deadline, a payment feed
|
|
809
|
+
marks sold lots paid, and each seller has a dashboard of their lots, bids and
|
|
810
|
+
bidders.
|
|
811
|
+
|
|
812
|
+
| Component | Block | Responsible for |
|
|
813
|
+
|---|---|---|
|
|
814
|
+
| `bidding_portal` | Portal (HTTP and WebSocket) | bids, watch subscriptions, admin commands |
|
|
815
|
+
| `bidding` | Orchestrator (workflow) | `place(lotId, bid)`: load the bidder, hand the bid to the live lot |
|
|
816
|
+
| `lot_lifecycle` | Orchestrator (workflow) | `open`, `close`, `cancel`, `markPaid` — shared by the deadline sweep, admins and the payment feed |
|
|
817
|
+
| `bid_rules` | Orchestrator, `pure` | `decide(lot, bid, bidder, policy, now)`, including the last-second extension |
|
|
818
|
+
| `seller_dashboard` | Orchestrator, `read` | a seller's lots with their bids and bidders, joined across two Repositories |
|
|
819
|
+
| `live_lot` | Actor | one open lot's working state, its watchers, its heartbeat |
|
|
820
|
+
| `live_lots` | Supervisor | find-or-start a live lot by id, stop it, unload idle ones |
|
|
821
|
+
| `deadline_sweep` | Actor | the process-wide tick that closes lots whose deadline has passed |
|
|
822
|
+
| `outbox_relay` | Actor | publishing outbox entries through the event bus and marking them delivered |
|
|
823
|
+
| `auction_runtime` | Supervisor (root) | starting and stopping `live_lots`, `deadline_sweep` and `outbox_relay` |
|
|
824
|
+
| `lot_repository` | Repository | the lot aggregate: a Store over the tables `lots` and `bids`, a Registry, an Index by id and by `endsAt`, and the Query `bid_history` |
|
|
825
|
+
| `bidder_repository` | Repository | the bidder aggregate |
|
|
826
|
+
| `outbox_repository` | Repository | the outbox aggregate |
|
|
827
|
+
| `auction_database` | Adapter | the database connection; issues transactions |
|
|
828
|
+
| `event_bus` | Adapter | publishing and subscribing to events |
|
|
829
|
+
| `watcher_sockets` | Adapter | pushing updates to watching clients over WebSocket |
|
|
830
|
+
| `payment_feed` | Observer | forwarding payment events to `lot_lifecycle.markPaid` |
|
|
831
|
+
|
|
832
|
+
```
|
|
833
|
+
bidding_portal → bidding, lot_lifecycle, seller_dashboard, live_lots, live_lot
|
|
834
|
+
bidding → bidder_repository, live_lots, live_lot
|
|
835
|
+
lot_lifecycle → live_lots, live_lot
|
|
836
|
+
seller_dashboard → lot_repository, bidder_repository
|
|
837
|
+
payment_feed → lot_lifecycle, event_bus
|
|
838
|
+
auction_runtime → live_lots, deadline_sweep, outbox_relay
|
|
839
|
+
live_lots → live_lot
|
|
840
|
+
live_lot → bid_rules, lot_repository, outbox_repository, auction_database, watcher_sockets
|
|
841
|
+
deadline_sweep → lot_repository, lot_lifecycle
|
|
842
|
+
outbox_relay → outbox_repository, event_bus
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
**Placing a bid.** `bidding_portal` hands the bid to `bidding.place`, which loads
|
|
846
|
+
the bidder through `bidder_repository`, asks `live_lots` for the live lot by id
|
|
847
|
+
(starting it, with its state loaded through `lot_repository`, when it is not
|
|
848
|
+
running) and calls `live_lot.placeBid(bid, bidder)`. That method is the Decider
|
|
849
|
+
shape (§3):
|
|
850
|
+
1. **decide** — `bid_rules.decide(lot, bid, bidder, policy, now)` accepts or
|
|
851
|
+
rejects the bid, and moves `endsAt` when the bid lands in the last seconds; a
|
|
852
|
+
rejection returns at once;
|
|
853
|
+
2. **persist** — begin a transaction through `auction_database`; in a `try`
|
|
854
|
+
region, write the bid and the lot's `endsAt` through `lot_repository` and
|
|
855
|
+
append a `bid_placed` entry through `outbox_repository`, both carrying the
|
|
856
|
+
context; commit; on failure, roll back and rethrow;
|
|
857
|
+
3. **apply** — only after the commit, update its own leading bid and `endsAt`;
|
|
858
|
+
4. **act** — push the new standing to the lot's watchers through `watcher_sockets`.
|
|
859
|
+
|
|
860
|
+
`outbox_relay` later publishes `bid_placed` through `event_bus` and marks the
|
|
861
|
+
entry delivered.
|
|
862
|
+
|
|
863
|
+
**Closing.** The deadline is the field `lot.endsAt`, which the lot Index keeps in
|
|
864
|
+
order. On each tick `deadline_sweep` reads the lots that are due through
|
|
865
|
+
`lot_repository` and calls `lot_lifecycle.close` for each; `close` reaches the live
|
|
866
|
+
lot through `live_lots`, the same single writer a bid goes through. `live_lot`
|
|
867
|
+
checks `endsAt` against its own state before it closes, because a last-second bid
|
|
868
|
+
it handled first may have moved the deadline.
|
|
869
|
+
|
|
870
|
+
**After a restart.** Nothing is preloaded. A lot's Actor starts again on its next
|
|
871
|
+
bid, watch or close; lots that fell due while the process was down close within one
|
|
872
|
+
tick; the relay republishes entries it had not marked delivered, and consumers drop
|
|
873
|
+
duplicates by event id.
|
|
874
|
+
|
|
875
|
+
**The names.** Each component is named for what it is responsible for: a process
|
|
876
|
+
(`bidding`, `lot_lifecycle`), a live thing (`live_lot`), a set of them
|
|
877
|
+
(`live_lots`), an aggregate's data (`lot_repository`), an external system
|
|
878
|
+
(`auction_database`, `event_bus`), an audience or a feed (`seller_dashboard`,
|
|
879
|
+
`payment_feed`). Logic without collaborators is named for the rules it holds
|
|
880
|
+
(`bid_rules`). A method is a verb phrase that does not repeat its component's
|
|
881
|
+
words — `bidding.place`, not `bidding.placeBid` — and arithmetic over one value's
|
|
882
|
+
own fields is a method on its type (`lot.isOpen(now)`). A role word appears only to
|
|
883
|
+
tell apart components that share a noun, and it matches the block:
|
|
884
|
+
`lot_repository` is a Repository.
|
|
885
|
+
|
|
886
|
+
**The rules it demonstrates.**
|
|
887
|
+
1. **A live Actor is addressed through its Supervisor.** `bidding`,
|
|
888
|
+
`lot_lifecycle` and `bidding_portal` reach a lot by id through `live_lots` and
|
|
889
|
+
never hold it; each lists `live_lot` together with `live_lots` in `dependsOn`,
|
|
890
|
+
since a call must target a declared dependency.
|
|
891
|
+
2. **One writer per live aggregate.** `live_lot` is the only writer of its lot in
|
|
892
|
+
the process: a close from the sweep or an admin goes through `live_lots` just as
|
|
893
|
+
a bid does. A cluster needs a lease or sharded routing to keep it so.
|
|
894
|
+
3. **The method that owns the workflow owns the transaction.** `live_lot.placeBid`
|
|
895
|
+
begins, commits and rolls back, and changes its in-memory state only after the
|
|
896
|
+
commit.
|
|
897
|
+
4. **Supervisors form a tree.** `auction_runtime` supervises `live_lots` and the
|
|
898
|
+
two process-wide Actors.
|
|
899
|
+
5. **A Portal messages a Supervisor by id**, as an Observer may: `bidding_portal`
|
|
900
|
+
adds a watcher to a live lot through `live_lots`.
|
|
901
|
+
6. **Deadlines are aggregate fields read by an Index.** A schedule aggregate is
|
|
902
|
+
only for timers that span aggregates.
|
|
903
|
+
7. **Reads across aggregates go through read logic.** `seller_dashboard` joins
|
|
904
|
+
`lot_repository` (its `bid_history` Query computes a lot's bids per call) with
|
|
905
|
+
`bidder_repository`; the outbox is a sibling Repository, written in the bid's
|
|
906
|
+
transaction.
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## 16. Summary of binding rules
|
|
911
|
+
|
|
912
|
+
1. Two axes: containment (folders) and refinement (the three files per component).
|
|
913
|
+
2. Ten building blocks only; dependency rules fixed; the **Adapter** is the single
|
|
914
|
+
external-I/O boundary.
|
|
915
|
+
3. A pattern owns **only blocks**, **one hop**, **never a pattern**; compose
|
|
916
|
+
patterns at L1. The one block that owns is a Supervisor, owning its
|
|
917
|
+
supervision state (its own Stores and Registries), private to it.
|
|
918
|
+
4. `owns` ≠ `dependsOn`; cross-group access is via facades only.
|
|
919
|
+
5. A Repository facade forwards 1:1 with no logic (single-`call`-to-owned-member
|
|
920
|
+
narratives; enforced as `FACADE_FORWARDING`).
|
|
921
|
+
6. State-owners (Store/Index for domain state; Supervisor/Actor for runtime state) ≠
|
|
922
|
+
components holding only collaborators (Orchestrator/Registry/Query/Portal/Observer).
|
|
923
|
+
7. All logic is a flowchart: an Orchestrator's methods, or an Actor's or
|
|
924
|
+
Supervisor's own methods over its runtime state. An Orchestrator's
|
|
925
|
+
`dependencyClass` bounds its dependencies — `pure` on pure logic only, `read` also
|
|
926
|
+
on read logic, Repositories, Indexes and Adapters, unset a workflow — and pure
|
|
927
|
+
logic may be used by every block. Local control flow → anywhere.
|
|
928
|
+
8. Behavior lives where it can be performed autonomously; coordinated/external
|
|
929
|
+
behavior lives on the acting block, taking the entity as an argument.
|
|
930
|
+
Composition + interfaces over inheritance.
|
|
931
|
+
9. Entities are first-class types, defined once by their owner; referenced, never
|
|
932
|
+
redefined, elsewhere. Interfaces are method contracts; fields are implementation.
|
|
933
|
+
10. The Actor owns one live thing and its methods are full flowcharts; the
|
|
934
|
+
Supervisor owns the set of Actors, may supervise Supervisors, owns its
|
|
935
|
+
supervision state, and reaches shared data only through read and lifecycle
|
|
936
|
+
methods — writes go through a workflow. A live Actor is reached through its
|
|
937
|
+
supervision — looked up by id in a Registry its Supervisor maintains — and is
|
|
938
|
+
the only writer of its aggregate; the method that owns a workflow owns its
|
|
939
|
+
transaction, and in-memory state changes only after commit. A lifecycle
|
|
940
|
+
method changes what exists, never domain fields, and calls only read and
|
|
941
|
+
lifecycle methods.
|
|
942
|
+
11. Structure is L2/L3; the concurrency variant is L4 (neutral strategy here,
|
|
943
|
+
primitives in the language-bindings appendix). The bus client, composition root,
|
|
944
|
+
and cross-cutting concerns are infrastructure, not blocks.
|
|
945
|
+
12. A Store covers one aggregate. Registry (write), Index and Query (read) are
|
|
946
|
+
decoupled — all work on the Store; Indexes are reference-sharing projections,
|
|
947
|
+
Queries compute per call inside their Repository, and the Registry updates
|
|
948
|
+
neither. Reads across aggregates go through read logic or a read-model Repository.
|
|
949
|
+
13. Right-size: L1, concurrency, and events are all optional; use the smallest set
|
|
950
|
+
of blocks a system needs, while upholding the same discipline at any scale.
|
|
951
|
+
14. A gateway is a Portal with the `gateway` variant. Variants load built-in →
|
|
952
|
+
global → project and carry guidance, not rules. `Specialist` and `Gateway` are
|
|
953
|
+
retired (`STEREOTYPE_RETIRED`) until migrated.
|