module-spec 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +90 -0
- package/README.md +132 -2
- package/bin/module-spec.js +193 -0
- package/lib/affected.js +54 -0
- package/lib/baseline.js +34 -0
- package/lib/branch.js +26 -0
- package/lib/check.js +53 -0
- package/lib/checks/api.js +36 -0
- package/lib/checks/clones.js +33 -0
- package/lib/checks/imports.js +101 -0
- package/lib/checks/manifests.js +52 -0
- package/lib/checks/ports.js +32 -0
- package/lib/checks/tables.js +37 -0
- package/lib/config.js +56 -0
- package/lib/files.js +56 -0
- package/lib/languages/go/go.mod +5 -0
- package/lib/languages/go/index.js +37 -0
- package/lib/languages/go/mutate.js +56 -0
- package/lib/languages/go/read.js +40 -0
- package/lib/languages/go/read_project.go +433 -0
- package/lib/languages/go/scaffold.js +28 -0
- package/lib/languages/go/templates/module/internal/shell/create.go +14 -0
- package/lib/languages/go/templates/module/{{pkg}}.go +15 -0
- package/lib/languages/go/templates/module/{{pkg}}_test.go +13 -0
- package/lib/languages/go/templates/port/internal/adapters/in_memory_{{portSnake}}.go +10 -0
- package/lib/languages/go/templates/port/internal/ports/{{portSnake}}.go +5 -0
- package/lib/languages/go/templates/port/{{portSnake}}_contract_test.go +20 -0
- package/lib/languages/index.js +66 -0
- package/lib/languages/python/environment.js +15 -0
- package/lib/languages/python/index.js +35 -0
- package/lib/languages/python/mutate.js +72 -0
- package/lib/languages/python/package.js +16 -0
- package/lib/languages/python/read.js +81 -0
- package/lib/languages/python/read_project.py +145 -0
- package/lib/languages/python/scaffold.js +19 -0
- package/lib/languages/python/templates/module/__init__.py +3 -0
- package/lib/languages/python/templates/module/shell/__init__.py +0 -0
- package/lib/languages/python/templates/module/shell/create.py +16 -0
- package/lib/languages/python/templates/module/tests/__init__.py +0 -0
- package/lib/languages/python/templates/module/tests/test_{{snake}}.py +5 -0
- package/lib/languages/python/templates/port/adapters/in_memory_{{portSnake}}.py +9 -0
- package/lib/languages/python/templates/port/ports/{{portSnake}}.py +5 -0
- package/lib/languages/python/templates/port/tests/test_{{portSnake}}_contract.py +11 -0
- package/lib/languages/typescript/index.js +37 -0
- package/lib/languages/typescript/mutate.js +83 -0
- package/lib/languages/typescript/project.js +24 -0
- package/lib/languages/typescript/read.js +220 -0
- package/lib/languages/typescript/scaffold.js +60 -0
- package/lib/languages/typescript/templates/module/index.ts +2 -0
- package/lib/languages/typescript/templates/module/shell/create{{Name}}.ts +8 -0
- package/lib/languages/typescript/templates/module-test.node.ts +7 -0
- package/lib/languages/typescript/templates/module-test.vitest.ts +6 -0
- package/lib/languages/typescript/templates/port/adapters/inMemory{{Port}}.ts +5 -0
- package/lib/languages/typescript/templates/port/ports/{{Port}}.ts +1 -0
- package/lib/languages/typescript/templates/port-test.node.ts +12 -0
- package/lib/languages/typescript/templates/port-test.vitest.ts +12 -0
- package/lib/languages/typescript/templates/view-module/__tests__/{{name}}.test.tsx +9 -0
- package/lib/languages/typescript/templates/view-module/core/model.ts +25 -0
- package/lib/languages/typescript/templates/view-module/index.ts +4 -0
- package/lib/languages/typescript/templates/view-module/shell/create{{Name}}Model.ts +20 -0
- package/lib/languages/typescript/templates/view-module/view/{{Name}}Page.tsx +14 -0
- package/lib/modules.js +104 -0
- package/lib/mutation.js +118 -0
- package/lib/place.js +25 -0
- package/lib/report.js +42 -0
- package/lib/rules.js +145 -0
- package/lib/scaffold.js +57 -0
- package/lib/scans.js +62 -0
- package/lib/templating.js +28 -0
- package/lib/tools.js +17 -0
- package/manifest.schema.json +35 -0
- package/package.json +38 -4
- package/spec/adopting.md +70 -0
- package/spec/backend.md +176 -0
- package/spec/common.md +163 -0
- package/spec/frontend.md +181 -0
- package/spec/roles.md +93 -0
- package/spec/tests.md +144 -0
- package/spec/workflow.md +50 -0
package/spec/backend.md
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# The inside of a backend module
|
|
2
|
+
|
|
3
|
+
The shape is hexagonal (Ports and Adapters, Alistair Cockburn, 2005) with a
|
|
4
|
+
functional core (Gary Bernhardt, "Boundaries", 2012): rules in the middle,
|
|
5
|
+
input and output at the edge, one door in.
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
---
|
|
9
|
+
config:
|
|
10
|
+
look: handDrawn
|
|
11
|
+
---
|
|
12
|
+
flowchart LR
|
|
13
|
+
caller["📞 caller<br/>an app or a test"]
|
|
14
|
+
subgraph pkg["📦 billing"]
|
|
15
|
+
door["🚪 door<br/>index.ts"]:::always
|
|
16
|
+
shell["🔁 shell<br/>load, call core, save"]:::always
|
|
17
|
+
core["🧠 core<br/>the rules, no input or output"]:::always
|
|
18
|
+
adapters["🔧 adapters<br/>Postgres, HTTP routes"]:::io
|
|
19
|
+
port["🔌 port<br/>InvoiceStore"]:::rare
|
|
20
|
+
end
|
|
21
|
+
outside["🌍 outside<br/>database, network, files"]
|
|
22
|
+
caller --> door --> shell
|
|
23
|
+
shell --> core
|
|
24
|
+
shell --> adapters
|
|
25
|
+
adapters --> outside
|
|
26
|
+
core -. "asks halfway" .-> port
|
|
27
|
+
adapters -. "implements" .-> port
|
|
28
|
+
classDef always fill:#1b4332,stroke:#52b788,color:#d8f3dc
|
|
29
|
+
classDef io fill:#5c3d0e,stroke:#f59e0b,color:#fef3c7
|
|
30
|
+
classDef rare fill:#3b1f5c,stroke:#c084fc,color:#f3e8ff
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Green parts every module with rules has. Amber appears when the module touches
|
|
34
|
+
the outside. Purple only when the core needs data halfway through a decision,
|
|
35
|
+
see [B4](#b4-ports-only-when-needed). `InvoiceStore` is an example.
|
|
36
|
+
|
|
37
|
+
## Layout
|
|
38
|
+
|
|
39
|
+
Each folder exists so one check can find it.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
billing/
|
|
43
|
+
module.yaml manifest C1
|
|
44
|
+
index.ts the door: re-exports only C3, C4
|
|
45
|
+
api-report.txt what the door exports C4
|
|
46
|
+
shell/ door functions: load, call core, save B2
|
|
47
|
+
core/ the rules, with no input or output B1
|
|
48
|
+
ports/ driven ports, only when needed B4, T3
|
|
49
|
+
adapters/ database, HTTP routes, files B3
|
|
50
|
+
__tests__/ tests through the door T1, T2
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
In Python the door is `__init__.py` and the tests folder is `tests/`. In Go the
|
|
54
|
+
door is the package at the module root, the parts sit under `internal/` so the
|
|
55
|
+
compiler refuses a deep import too, and tests sit at the module root in the
|
|
56
|
+
`_test` package. The rest is the same.
|
|
57
|
+
|
|
58
|
+
`ports/` and `adapters/` exist only when needed. A pure calculation module may
|
|
59
|
+
be only `module.yaml`, the door, `core/` and the tests. A module that only moves
|
|
60
|
+
data between HTTP and a table has no rules, so it has no `core/` and no ports.
|
|
61
|
+
Tests may also sit in a `__tests__/` folder inside a part. They still reach the
|
|
62
|
+
module only through its door ([T1](tests.md#t1-tests-reach-a-module-only-through-its-door)).
|
|
63
|
+
|
|
64
|
+
## B1. Core is pure
|
|
65
|
+
|
|
66
|
+
`core/` takes data and returns data. It reaches the outside only through its
|
|
67
|
+
own module's ports, so it uses:
|
|
68
|
+
|
|
69
|
+
- no input/output code: none of the standard library's input/output modules
|
|
70
|
+
(files, network, processes, such as `fs`, `os` or `socket`), no global that
|
|
71
|
+
does input/output (`fetch`, `open`), and no third-party package the project
|
|
72
|
+
has not listed as pure. The rule is the same in every language;
|
|
73
|
+
- nothing from its own `shell/` or `adapters/`;
|
|
74
|
+
- no other module that carries adapters. Ask for it through a port instead.
|
|
75
|
+
An import the language erases before the code runs may name that module's
|
|
76
|
+
types, because it loads nothing: TypeScript's `import type`, or Python's
|
|
77
|
+
`if TYPE_CHECKING:`. TypeScript's `import { type T }` does not count, since
|
|
78
|
+
Node still loads the module. Go has no such import.
|
|
79
|
+
|
|
80
|
+
A core with no `ports/` folder also never waits: no `async`, no `await`. A
|
|
81
|
+
core that has to wait on something is calling out, so it needs a port. Once
|
|
82
|
+
the module has `ports/`, its core may await them.
|
|
83
|
+
|
|
84
|
+
**Why:** a pure core is tested in one line with no mocks. "Imports nothing
|
|
85
|
+
impure" is a check a machine can run. "Is this the right port" is not.
|
|
86
|
+
**Check:** the import checker, plus a scan of each core for input/output
|
|
87
|
+
globals and, when it has no ports, for `async`. A third-party package counts
|
|
88
|
+
as input/output until the project lists it under `pure` in `module-spec.json`,
|
|
89
|
+
so a new database driver fails though nobody named it (fail-safe defaults,
|
|
90
|
+
Jerome Saltzer and Michael Schroeder, "The Protection of Information in
|
|
91
|
+
Computer Systems", 1975). The kit names no third-party package. Each language
|
|
92
|
+
names only the input/output modules of its own standard library, a fixed set.
|
|
93
|
+
|
|
94
|
+
## B2. Shell is thin
|
|
95
|
+
|
|
96
|
+
A door function loads what the core needs, calls the core, and saves what the
|
|
97
|
+
core returns.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
// shell
|
|
101
|
+
const invoice = await db.loadInvoice(id)
|
|
102
|
+
await db.saveInvoice(settle(invoice, payment)) // settle() is the core
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
**Why:** rules that leak into the shell escape B1.
|
|
106
|
+
**Check:** a complexity cap on `shell/`. Leaked rules add branches and trip
|
|
107
|
+
it. The cap is a proxy: an agent can split a function to slip under it, so the
|
|
108
|
+
clone ratchet and review cover the rest.
|
|
109
|
+
|
|
110
|
+
## B3. Only adapters touch the outside
|
|
111
|
+
|
|
112
|
+
Only `adapters/` imports database drivers, HTTP frameworks, and file or
|
|
113
|
+
network libraries. HTTP routes live in `adapters/`, because a route changes
|
|
114
|
+
when its feature changes. An app module is exempt: its composition root opens
|
|
115
|
+
the connections.
|
|
116
|
+
|
|
117
|
+
**Check:** the import checker, with the same lists as
|
|
118
|
+
[B1](#b1-core-is-pure): outside `adapters/`, a third-party package must be
|
|
119
|
+
listed under `pure`.
|
|
120
|
+
|
|
121
|
+
## B4. Ports only when needed
|
|
122
|
+
|
|
123
|
+
Start every module as a functional core with no driven ports. Add a driven
|
|
124
|
+
port only when the core cannot know what data it needs until it has decided
|
|
125
|
+
part of the way, for example "if overdue, fetch the client's payment plan".
|
|
126
|
+
|
|
127
|
+
A port is defined in `ports/`, named after the core's need (`InvoiceStore`),
|
|
128
|
+
not the technology (`PostgresInvoiceRepo`). Adapters implement it (dependency
|
|
129
|
+
inversion, Robert C. Martin).
|
|
130
|
+
|
|
131
|
+
`new-port` writes the port, an in-memory fake and the contract test in one
|
|
132
|
+
step, so the right fix is cheaper than any workaround.
|
|
133
|
+
|
|
134
|
+
**Why:** when a needed port is missing, an agent either imports input/output
|
|
135
|
+
code into the core (B1 fails) or moves rules into the shell (B2 fails). Both
|
|
136
|
+
errors point to `new-port`. That leaves the port as the only path.
|
|
137
|
+
|
|
138
|
+
## B6. Two kinds of error
|
|
139
|
+
|
|
140
|
+
- **Expected outcomes** are return values. The door's types list them.
|
|
141
|
+
- **Bugs and outages** are thrown and caught once, at the top of the app.
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
type PayResult =
|
|
145
|
+
| { ok: true; invoice: Invoice }
|
|
146
|
+
| { ok: false; error: 'already-paid' | 'amount-mismatch' }
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Adapters translate driver errors into the module's own errors. A Postgres
|
|
150
|
+
error code that reaches a caller leaks the decision "billing uses Postgres".
|
|
151
|
+
Where possible, design errors out of existence: deleting a missing item can
|
|
152
|
+
simply do nothing (Ousterhout, *A Philosophy of Software Design*).
|
|
153
|
+
|
|
154
|
+
**Check:** the API report ([C4](common.md#c4-the-door-stays-small)) lists
|
|
155
|
+
every error type at the door, so a leaked driver error shows in the diff.
|
|
156
|
+
|
|
157
|
+
## B7. Parse inputs at the edge
|
|
158
|
+
|
|
159
|
+
Adapters turn raw input into the module's own types: HTTP bodies, and database
|
|
160
|
+
rows too. The door and the core accept only those types, so a raw `string`
|
|
161
|
+
cannot pass as an `InvoiceId` (Alexis King, "Parse, don't validate", 2019).
|
|
162
|
+
What a value is goes in a type. What to do with it goes in a test.
|
|
163
|
+
|
|
164
|
+
**Check:** the project's own type checker, not this kit. A distinct type per
|
|
165
|
+
kind of value turns the mix-up into a type error.
|
|
166
|
+
|
|
167
|
+
## Languages
|
|
168
|
+
|
|
169
|
+
Each rule is written once, for every language. The kit reads each language
|
|
170
|
+
with that language's own tools, then applies every rule the same way, so a
|
|
171
|
+
rule has one wording and one error message everywhere. Supported today:
|
|
172
|
+
TypeScript, Python and Go.
|
|
173
|
+
|
|
174
|
+
Where a language's compiler can enforce a rule, prefer it, as Go's `internal/`
|
|
175
|
+
does for the door. In Rust, the core would be its own crate with no database
|
|
176
|
+
or async runtime in its `Cargo.toml`.
|
package/spec/common.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Rules for every module
|
|
2
|
+
|
|
3
|
+
These rules hold for every module, backend or frontend, whatever its role.
|
|
4
|
+
Each rule has an ID, and `module-spec check` prints it with a link to the
|
|
5
|
+
rule's heading here.
|
|
6
|
+
|
|
7
|
+
A module is a folder with one door. Everything behind the door is private. The
|
|
8
|
+
goal is a deep module: a small door over a lot of hidden work (John Ousterhout,
|
|
9
|
+
*A Philosophy of Software Design*). Each module hides one decision, so a change
|
|
10
|
+
to that decision touches one module (David Parnas, "On the Criteria To Be Used
|
|
11
|
+
in Decomposing Systems into Modules", 1972).
|
|
12
|
+
|
|
13
|
+
A project with one module needs none of this. Turn the checks on at the second
|
|
14
|
+
module.
|
|
15
|
+
|
|
16
|
+
## C1. Every module has a manifest
|
|
17
|
+
|
|
18
|
+
`module.yaml` sits at the module root. Its shape is
|
|
19
|
+
[manifest.schema.json](../manifest.schema.json).
|
|
20
|
+
|
|
21
|
+
```yaml
|
|
22
|
+
role: feature
|
|
23
|
+
owns: how an engagement's fee is computed
|
|
24
|
+
depends_on: [db, money]
|
|
25
|
+
owns_tables: [invoice, invoice_line]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`role` is one of [the roles](roles.md). A module that has its own
|
|
29
|
+
`package.json` lists its dependencies there, not in `depends_on`, so the two
|
|
30
|
+
never disagree. Modules do not nest.
|
|
31
|
+
|
|
32
|
+
**Why:** the checks read the manifest. Without it, "who may import whom" is a
|
|
33
|
+
guess.
|
|
34
|
+
**Check:** schema validation, a door, a unique name, no module inside another.
|
|
35
|
+
|
|
36
|
+
## C2. `owns` names a decision, not an activity
|
|
37
|
+
|
|
38
|
+
`owns` says in one line what this module alone decides. "How fees are computed"
|
|
39
|
+
passes. "Handles invoices" fails.
|
|
40
|
+
|
|
41
|
+
**Why:** a module split by activity (validate, save, notify) makes one change
|
|
42
|
+
touch every module. A module split by decision keeps the change in one place.
|
|
43
|
+
This is the single responsibility principle read as "one reason to change"
|
|
44
|
+
(Robert C. Martin, *Clean Architecture*, 2017), not "does one thing".
|
|
45
|
+
**Check:** the schema rejects the activity words "handles", "manages",
|
|
46
|
+
"utils", "helpers" and "misc", the word "TODO", and more than one line.
|
|
47
|
+
|
|
48
|
+
## C3. One door
|
|
49
|
+
|
|
50
|
+
Other code reaches a module only through its door, and a module has exactly
|
|
51
|
+
one. The door's place is fixed per language, so the manifest has no field for
|
|
52
|
+
it: `index.ts` in TypeScript, `__init__.py` in Python, the package at the module
|
|
53
|
+
root in Go. In a workspace package,
|
|
54
|
+
`package.json` `exports` maps only `.` to the door.
|
|
55
|
+
|
|
56
|
+
**Why:** once callers use only the door, everything behind it can be rebuilt
|
|
57
|
+
without touching them. A second door is a second surface for agents to widen.
|
|
58
|
+
**Check:** the import checker.
|
|
59
|
+
|
|
60
|
+
## C4. The door stays small
|
|
61
|
+
|
|
62
|
+
Export only what callers need. `module-spec api` writes `api-report.txt` in the
|
|
63
|
+
module: every export of every door, with its type (TypeScript) or its
|
|
64
|
+
`__all__` list (Python). The report is committed, so any change to the door
|
|
65
|
+
shows in the diff.
|
|
66
|
+
|
|
67
|
+
**Why:** agents tend to export whatever they touch. A wide door is a shallow
|
|
68
|
+
module.
|
|
69
|
+
**Check:** `api-report.txt` matches the door, and a Python door declares
|
|
70
|
+
`__all__`.
|
|
71
|
+
|
|
72
|
+
## C5. Dependencies are declared
|
|
73
|
+
|
|
74
|
+
A module imports exactly the modules it declares, in `depends_on` or in its
|
|
75
|
+
`package.json`. Every declared module is imported somewhere in it.
|
|
76
|
+
|
|
77
|
+
**Why:** an undeclared dependency is an edge nobody decided on. A declared one
|
|
78
|
+
nothing uses is a lie about the design.
|
|
79
|
+
**Check:** the import checker compares imports with the declaration.
|
|
80
|
+
|
|
81
|
+
## C6. No cycles
|
|
82
|
+
|
|
83
|
+
No module depends on itself through other modules.
|
|
84
|
+
|
|
85
|
+
**Check:** the declared dependencies form no cycle, and C5 keeps the imports
|
|
86
|
+
inside the declarations.
|
|
87
|
+
|
|
88
|
+
## C8. One composition root wires everything
|
|
89
|
+
|
|
90
|
+
Each app module holds one composition root, such as `app/main.ts` (Mark
|
|
91
|
+
Seemann, *Dependency Injection in .NET*, 2011). It is the only place that
|
|
92
|
+
reads the environment or opens connections. Each module's door exports one
|
|
93
|
+
`createX(deps)` function. The module builds its own adapters from the handles
|
|
94
|
+
in `deps`, so its adapters stay private.
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// app/main.ts
|
|
98
|
+
const db = openPool(env.DATABASE_URL)
|
|
99
|
+
const billing = createBilling({ db, clock: systemClock })
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Why:** one file shows how the whole app is built. A module never builds
|
|
103
|
+
itself in secret, so a test can pass in fakes.
|
|
104
|
+
**Check:** only app modules read the environment.
|
|
105
|
+
|
|
106
|
+
## C9. Modules talk through the app
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
// billing declares nothing about reports, and reports imports nothing from billing.
|
|
110
|
+
const reports = createReports({ fees: billing }) // reports asks; billing fits its Fees port
|
|
111
|
+
billing.onInvoicePaid(notifications.sendReceipt) // billing tells, unaware of who listens
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
- **To ask**, a feature declares the port it needs as a type in its own core,
|
|
115
|
+
such as `Fees`. The app passes the other feature's door, which fits the port.
|
|
116
|
+
The two features never import each other ([R1](roles.md#r1-a-feature-imports-only-shared-modules)).
|
|
117
|
+
- **To tell**, a module exposes an `onX` hook, and the app attaches the
|
|
118
|
+
listeners. The module never imports its listeners.
|
|
119
|
+
- **No global event bus.** It hides who listens, and agents cannot see what
|
|
120
|
+
they cannot search for.
|
|
121
|
+
|
|
122
|
+
**Why:** every wire is in one file, and features stay independent.
|
|
123
|
+
**Check:** R1 and C5.
|
|
124
|
+
|
|
125
|
+
## C10. Only the owner writes its tables
|
|
126
|
+
|
|
127
|
+
Where there is SQL, each table has one owning module, listed in `owns_tables`.
|
|
128
|
+
Only the owner writes it. Others read through the owner's door, or read the
|
|
129
|
+
table but never write it. The services form of this rule is "database per
|
|
130
|
+
service" (Chris Richardson, *Microservices Patterns*, 2018).
|
|
131
|
+
|
|
132
|
+
**Why:** a table's notion of a valid row is a decision. Two writers means two
|
|
133
|
+
versions of that decision.
|
|
134
|
+
**Check:** upper-case SQL that writes a table (`INSERT INTO`, `UPDATE`,
|
|
135
|
+
`DELETE FROM`, `REPLACE INTO`, `MERGE INTO`) appears only in its owner, and no
|
|
136
|
+
two manifests own one table.
|
|
137
|
+
|
|
138
|
+
## C11. Cross-module clones only go down
|
|
139
|
+
|
|
140
|
+
**Why:** zero imports between two modules does not mean zero coupling. A rule
|
|
141
|
+
copied into two modules is coupling that no import checker sees, and the
|
|
142
|
+
copies drift apart.
|
|
143
|
+
**Check:** a clone detector counts clones of at least six lines whose copies
|
|
144
|
+
sit in different modules, tests left out. The count is a ratchet, kept in the
|
|
145
|
+
[baseline](adopting.md#the-baseline): it may stay or fall, never rise. A
|
|
146
|
+
module a branch changes may sit in no clone at all.
|
|
147
|
+
|
|
148
|
+
## When to split or merge
|
|
149
|
+
|
|
150
|
+
These are a map, not checks. They raise a flag. A person decides where the
|
|
151
|
+
edge goes.
|
|
152
|
+
`module-spec report` shows them.
|
|
153
|
+
|
|
154
|
+
- **Split** when a module's rules form two clusters that never import each
|
|
155
|
+
other and never change in the same commit. That is two decisions in one
|
|
156
|
+
folder.
|
|
157
|
+
- **Split** when `owns` no longer fits in one line.
|
|
158
|
+
- **Merge** when two modules always change in the same commits. The split was
|
|
159
|
+
shallow.
|
|
160
|
+
|
|
161
|
+
Start a new project with a few large modules, one per business capability (a
|
|
162
|
+
bounded context, Eric Evans, *Domain-Driven Design*, 2003). An early wrong
|
|
163
|
+
split costs more than a late right one.
|
package/spec/frontend.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# The inside of a frontend module
|
|
2
|
+
|
|
3
|
+
A frontend module has the backend's shape plus one part, `view/`. State and the
|
|
4
|
+
rules that change it sit in the core, the shell runs effects, and the view
|
|
5
|
+
draws. This is Model-View-Update, also called The Elm Architecture (Evan
|
|
6
|
+
Czaplicki). The view is a Humble Object (Gerard Meszaros, *xUnit Test
|
|
7
|
+
Patterns*, 2007): code so simple it needs no tests of its own.
|
|
8
|
+
|
|
9
|
+
```mermaid
|
|
10
|
+
---
|
|
11
|
+
config:
|
|
12
|
+
look: handDrawn
|
|
13
|
+
---
|
|
14
|
+
flowchart LR
|
|
15
|
+
user["👤 user"]:::outside
|
|
16
|
+
app["🚀 web app<br/>router, wiring"]:::part
|
|
17
|
+
subgraph checkout["📦 checkout (feature)"]
|
|
18
|
+
door["🚪 index.ts"]:::part
|
|
19
|
+
view["🖼️ view/<br/>state in, events out"]:::new
|
|
20
|
+
shell["🔁 shell/<br/>holds state, runs effects"]:::part
|
|
21
|
+
core["🧠 core/<br/>update(state, event)<br/>selectors"]:::part
|
|
22
|
+
port["🔌 ports/CheckoutApi"]:::part
|
|
23
|
+
adapter["🔧 adapters/httpCheckoutApi"]:::part
|
|
24
|
+
end
|
|
25
|
+
api["🌐 server API"]:::outside
|
|
26
|
+
app --> door
|
|
27
|
+
user -- click --> view
|
|
28
|
+
view -- "send(event)" --> shell
|
|
29
|
+
shell --> core
|
|
30
|
+
shell -- "new state" --> view
|
|
31
|
+
shell --> port
|
|
32
|
+
adapter -. implements .-> port
|
|
33
|
+
adapter --> api
|
|
34
|
+
classDef new fill:#1b4332,stroke:#52b788,color:#d8f3dc
|
|
35
|
+
classDef part fill:#2b2b2b,stroke:#9ca3af,color:#e5e7eb
|
|
36
|
+
classDef outside fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Green: the one part a frontend adds. Grey: the same parts as a backend module.
|
|
40
|
+
Blue: outside the module.
|
|
41
|
+
|
|
42
|
+
## Layout
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
checkout/
|
|
46
|
+
module.yaml manifest C1
|
|
47
|
+
index.ts the door: re-exports only C3, C4
|
|
48
|
+
core/ state, update(state, event), selectors B1
|
|
49
|
+
shell/ the model: holds state, runs effects B2
|
|
50
|
+
view/ components: state in, events out F1, F2, F3
|
|
51
|
+
ports/ CheckoutApi, only when needed B4, T3
|
|
52
|
+
adapters/ the fetch client B3
|
|
53
|
+
__tests__/ tests through the door T1, T2
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Every rule in [common.md](common.md), [roles.md](roles.md) and
|
|
57
|
+
[tests.md](tests.md) holds here too, and B1 to B4 in
|
|
58
|
+
[backend.md](backend.md) hold for the parts they name.
|
|
59
|
+
|
|
60
|
+
The door exports two things: the shell's `createCheckoutModel(deps)` and the
|
|
61
|
+
view's `CheckoutPage`. The model's type lives in the core, so the view and the
|
|
62
|
+
shell never import each other. The app joins them:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// core/checkout.ts
|
|
66
|
+
export interface CheckoutModel {
|
|
67
|
+
state(): CheckoutState
|
|
68
|
+
subscribe(listener: () => void): () => void
|
|
69
|
+
send(event: CheckoutEvent): void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// view/CheckoutPage.tsx
|
|
73
|
+
export function CheckoutPage({ model }: { model: CheckoutModel }) {
|
|
74
|
+
const state = useSyncExternalStore(model.subscribe, model.state, model.state)
|
|
75
|
+
return <button onClick={() => model.send({ type: "paid" })}>{payLabel(state)}</button>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// the web app's composition root
|
|
79
|
+
const model = createCheckoutModel({ api: httpCheckoutApi(env.API_URL) })
|
|
80
|
+
root.render(<CheckoutPage model={model} />)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## F1. Only view/ knows the UI framework
|
|
84
|
+
|
|
85
|
+
Only `view/` imports the UI framework: React, Vue, Svelte, a GUI or a terminal
|
|
86
|
+
UI toolkit. The core, shell, ports and adapters are plain code. An app module
|
|
87
|
+
is exempt, because its composition root starts the framework. Tests are exempt,
|
|
88
|
+
because they render the view.
|
|
89
|
+
|
|
90
|
+
**Why:** the UI framework is the decision most likely to change, and a mobile
|
|
91
|
+
app needs other views over the same model. With the framework in one part,
|
|
92
|
+
either change rewrites `view/` and nothing else (information hiding, David
|
|
93
|
+
Parnas, 1972).
|
|
94
|
+
**Check:** the import checker. The project lists its UI packages, such as
|
|
95
|
+
`react` or a component library, under `ui` in `module-spec.json`, and
|
|
96
|
+
`new-module --view` lists the ones its template uses. A UI module in the
|
|
97
|
+
standard library, such as Python's tkinter, needs no entry. In TypeScript, JSX
|
|
98
|
+
outside `view/` counts too, because JSX needs no import of the framework.
|
|
99
|
+
|
|
100
|
+
## F2. A view only renders
|
|
101
|
+
|
|
102
|
+
A view gets its state and a `send` function from the model, and draws. It
|
|
103
|
+
imports only the core, other files in `view/`, the UI framework, and other
|
|
104
|
+
modules that carry no adapters. It never imports the shell, ports or adapters,
|
|
105
|
+
and never touches the outside: no `fetch`, no storage, no file or network
|
|
106
|
+
module.
|
|
107
|
+
|
|
108
|
+
A view may still use the framework's own tools for the screen, such as an
|
|
109
|
+
effect that focuses an input. With no way to reach the outside, that is all
|
|
110
|
+
such an effect can do.
|
|
111
|
+
|
|
112
|
+
**Why:** data moves one way: event, shell, core, new state, view. A view that
|
|
113
|
+
fetches breaks that loop, and its test needs a network mock, which
|
|
114
|
+
[T2](tests.md#t2-tests-pass-fakes-in-never-patch) bans.
|
|
115
|
+
**Check:** the import checker, plus the same input/output scan as a core
|
|
116
|
+
([B1](backend.md#b1-core-is-pure)).
|
|
117
|
+
|
|
118
|
+
## F3. A view holds no rules
|
|
119
|
+
|
|
120
|
+
A view decides only how things look. A business decision, such as "is shipping
|
|
121
|
+
free for this cart", is a selector in the core that the view calls.
|
|
122
|
+
|
|
123
|
+
**Why:** a rule in a view escapes B1, and it can be tested only by drawing the
|
|
124
|
+
page.
|
|
125
|
+
**Check:** the same complexity cap as the shell
|
|
126
|
+
([B2](backend.md#b2-shell-is-thin)), `maxComplexity` in `module-spec.json`.
|
|
127
|
+
Rules add branches and trip it. Like B2's cap, it is a proxy, so the clone
|
|
128
|
+
ratchet and review cover the rest.
|
|
129
|
+
|
|
130
|
+
## Tests
|
|
131
|
+
|
|
132
|
+
A test builds the model through the door with a fake passed in, draws the
|
|
133
|
+
page, acts like the user and reads the screen. "The more your tests resemble
|
|
134
|
+
the way your software is used, the more confidence they can give you" (Kent C.
|
|
135
|
+
Dodds, Testing Library's guiding principle).
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
test("paying shows the receipt", async () => {
|
|
139
|
+
const model = createCheckoutModel({ api: inMemoryCheckoutApi({ due: 300 }) })
|
|
140
|
+
render(<CheckoutPage model={model} />)
|
|
141
|
+
await user.click(screen.getByRole("button", { name: "Pay $3.00" }))
|
|
142
|
+
await screen.findByText("Paid")
|
|
143
|
+
})
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
[T1](tests.md#t1-tests-reach-a-module-only-through-its-door) and
|
|
147
|
+
[T2](tests.md#t2-tests-pass-fakes-in-never-patch) hold as they are: the test
|
|
148
|
+
imports only the door, and it never mocks `fetch`, because the fake API comes
|
|
149
|
+
in through `createCheckoutModel`.
|
|
150
|
+
|
|
151
|
+
## Across features
|
|
152
|
+
|
|
153
|
+
- **Routing lives in the app.** A feature never imports another feature's
|
|
154
|
+
page ([R1](roles.md#r1-a-feature-imports-only-shared-modules)). To link to
|
|
155
|
+
one, it takes a URL or a `navigate` function from the app through its deps.
|
|
156
|
+
- **No global store.** State that several features read, such as the
|
|
157
|
+
signed-in user, is a shared module the app creates once and passes in
|
|
158
|
+
([C9](common.md#c9-modules-talk-through-the-app)).
|
|
159
|
+
- **A design system is a shared module.** Its components live in its own
|
|
160
|
+
`view/`.
|
|
161
|
+
|
|
162
|
+
## Why not Feature-Sliced Design
|
|
163
|
+
|
|
164
|
+
Feature-Sliced Design is the best-known frontend folder standard. It has six
|
|
165
|
+
layers (app, pages, widgets, features, entities, shared) against this spec's
|
|
166
|
+
three roles, so a project would have two vocabularies. Its `model` segment
|
|
167
|
+
mixes rules with data fetching, and it has no ports, so its tests mock the
|
|
168
|
+
`api` segment, which T2 bans. This shape is the backend's, plus `view/`, so
|
|
169
|
+
every check already in place keeps working.
|
|
170
|
+
|
|
171
|
+
## Languages
|
|
172
|
+
|
|
173
|
+
TypeScript reads `.ts` and `.tsx`, so React, Preact, Solid and any JSX
|
|
174
|
+
framework work. Vue and Svelte single-file components (`.vue`, `.svelte`) are
|
|
175
|
+
not read yet. Python and Go apply the same rules to any GUI or terminal UI
|
|
176
|
+
toolkit the project lists under `ui`, such as Qt or Bubble Tea, and Python to
|
|
177
|
+
tkinter with no entry.
|
|
178
|
+
|
|
179
|
+
`module-spec new-module <dir> --view` writes a React module in this shape,
|
|
180
|
+
with a test. It needs `react` and `vitest` in the project, and is TypeScript
|
|
181
|
+
only for now.
|
package/spec/roles.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Roles: app, feature, shared
|
|
2
|
+
|
|
3
|
+
Every module has a `role` in its manifest. The role decides which modules it may
|
|
4
|
+
import, on the backend and the frontend alike. The inside layout is a separate
|
|
5
|
+
matter, see [backend.md](backend.md) and [frontend.md](frontend.md).
|
|
6
|
+
|
|
7
|
+
```mermaid
|
|
8
|
+
---
|
|
9
|
+
config:
|
|
10
|
+
look: handDrawn
|
|
11
|
+
---
|
|
12
|
+
flowchart TB
|
|
13
|
+
subgraph apps["🚀 apps: one per thing you run"]
|
|
14
|
+
direction LR
|
|
15
|
+
server["🖥️ server"]:::app
|
|
16
|
+
web["🌐 web"]:::app
|
|
17
|
+
mobile["📱 mobile"]:::app
|
|
18
|
+
cli["⌨️ cli"]:::app
|
|
19
|
+
end
|
|
20
|
+
subgraph features["🏢 features: what the project does"]
|
|
21
|
+
direction LR
|
|
22
|
+
billing["billing"]:::feature
|
|
23
|
+
reports["reports"]:::feature
|
|
24
|
+
end
|
|
25
|
+
subgraph shared["🔧 shared: what features build on"]
|
|
26
|
+
direction LR
|
|
27
|
+
money["money<br/>how amounts round"]:::shared
|
|
28
|
+
db["db<br/>how we talk to Postgres"]:::shared
|
|
29
|
+
end
|
|
30
|
+
apps -- "may import" --> features
|
|
31
|
+
features -- "may import" --> shared
|
|
32
|
+
apps -- "may import" --> shared
|
|
33
|
+
server -. "❌ never" .-> web
|
|
34
|
+
billing -. "❌ never" .-> reports
|
|
35
|
+
classDef app fill:#1b4332,stroke:#52b788,color:#d8f3dc
|
|
36
|
+
classDef feature fill:#3b1f5c,stroke:#c084fc,color:#f3e8ff
|
|
37
|
+
classDef shared fill:#1e3a5f,stroke:#60a5fa,color:#dbeafe
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The module names are examples. Imports point only downward.
|
|
41
|
+
|
|
42
|
+
| Role | What it is | May import |
|
|
43
|
+
|-----------|----------------------------------------------------------------|-----------------------------|
|
|
44
|
+
| `app` | one thing you run: a server, a web app, a CLI. Holds the composition root | features and shared modules |
|
|
45
|
+
| `feature` | something the project does: a page, a job, a command | shared modules |
|
|
46
|
+
| `shared` | what two or more features build on | shared modules |
|
|
47
|
+
|
|
48
|
+
The roles follow Ports and Adapters (Alistair Cockburn, 2005), the bounded
|
|
49
|
+
context and shared kernel (Eric Evans, *Domain-Driven Design*, 2003) and the
|
|
50
|
+
acyclic dependencies principle (Robert C. Martin).
|
|
51
|
+
|
|
52
|
+
## R1. A feature imports only shared modules
|
|
53
|
+
|
|
54
|
+
A feature never imports another feature, not even through its door. When one
|
|
55
|
+
feature needs another, it declares the port it needs as a type in its own core,
|
|
56
|
+
and the app passes the other feature's door in through `createX(deps)`. See
|
|
57
|
+
[C9](common.md#c9-modules-talk-through-the-app).
|
|
58
|
+
|
|
59
|
+
**Why:** features that import each other are one tangle with folders drawn on
|
|
60
|
+
it. A change inside one breaks the other, and neither can be tested or removed
|
|
61
|
+
alone. A door does not fix this: it grows to re-export whatever the next
|
|
62
|
+
caller needs.
|
|
63
|
+
**Check:** the import checker, and the manifest check on what each module
|
|
64
|
+
declares.
|
|
65
|
+
|
|
66
|
+
## R2. A shared module imports only shared modules
|
|
67
|
+
|
|
68
|
+
**Why:** dependencies point toward what changes least (the stable dependencies
|
|
69
|
+
principle, Robert C. Martin, *Agile Software Development*, 2002). A shared
|
|
70
|
+
module that imports a feature changes whenever that feature does, and so does
|
|
71
|
+
every feature built on it.
|
|
72
|
+
**Check:** the import checker.
|
|
73
|
+
|
|
74
|
+
## R3. An app imports no other app
|
|
75
|
+
|
|
76
|
+
**Why:** two apps that share code through each other deploy as one.
|
|
77
|
+
**Check:** the import checker.
|
|
78
|
+
|
|
79
|
+
## When code moves between roles
|
|
80
|
+
|
|
81
|
+
Code a second feature needs moves to a shared module when that second user
|
|
82
|
+
arrives, and back into its feature when only one user is left.
|
|
83
|
+
|
|
84
|
+
## What a shared module holds
|
|
85
|
+
|
|
86
|
+
Either rules that several features use, such as how money rounds, or plumbing
|
|
87
|
+
any business could use, such as a database pool, a clock or a logger. For
|
|
88
|
+
plumbing, `owns` names the technical decision: "how we talk to Postgres".
|
|
89
|
+
|
|
90
|
+
Many modules build on a shared module, so a change to its door ripples
|
|
91
|
+
everywhere. `module-spec report` lists each module's fan-in, so the most
|
|
92
|
+
depended-on doors are easy to watch. Plumbing often has no rules, so it may be
|
|
93
|
+
only a door and adapters, with no `core/`.
|