sandcastle-drain 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/LICENSE +21 -0
- package/README.md +108 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +139 -0
- package/dist/cli.js.map +1 -0
- package/dist/content/agent-docs/issue-tracker.md +22 -0
- package/dist/content/agent-docs/sandcastle-windows-cleanup.md +45 -0
- package/dist/content/agent-docs/triage-labels.md +101 -0
- package/dist/content/principles/README.md +39 -0
- package/dist/content/principles/architecture.md +124 -0
- package/dist/content/principles/claude-code-modes.md +47 -0
- package/dist/content/principles/clean-code.md +102 -0
- package/dist/content/principles/context-budget.md +81 -0
- package/dist/content/principles/cqrs.md +70 -0
- package/dist/content/principles/domain-modeling.md +62 -0
- package/dist/content/principles/frontend-organization.md +120 -0
- package/dist/content/principles/language-and-types.md +85 -0
- package/dist/content/principles/linting-and-tooling.md +122 -0
- package/dist/content/principles/personal-use-tradeoffs.md +55 -0
- package/dist/content/principles/testing.md +89 -0
- package/dist/orchestrator/blocked-by.d.ts +17 -0
- package/dist/orchestrator/blocked-by.d.ts.map +1 -0
- package/dist/orchestrator/blocked-by.js +48 -0
- package/dist/orchestrator/blocked-by.js.map +1 -0
- package/dist/orchestrator/ci-gate.d.ts +28 -0
- package/dist/orchestrator/ci-gate.d.ts.map +1 -0
- package/dist/orchestrator/ci-gate.js +198 -0
- package/dist/orchestrator/ci-gate.js.map +1 -0
- package/dist/orchestrator/main.d.ts +10 -0
- package/dist/orchestrator/main.d.ts.map +1 -0
- package/dist/orchestrator/main.js +883 -0
- package/dist/orchestrator/main.js.map +1 -0
- package/dist/orchestrator/prereqs.d.ts +30 -0
- package/dist/orchestrator/prereqs.d.ts.map +1 -0
- package/dist/orchestrator/prereqs.js +191 -0
- package/dist/orchestrator/prereqs.js.map +1 -0
- package/dist/orchestrator/rejection.d.ts +60 -0
- package/dist/orchestrator/rejection.d.ts.map +1 -0
- package/dist/orchestrator/rejection.js +187 -0
- package/dist/orchestrator/rejection.js.map +1 -0
- package/dist/orchestrator/reviewer.d.ts +75 -0
- package/dist/orchestrator/reviewer.d.ts.map +1 -0
- package/dist/orchestrator/reviewer.js +260 -0
- package/dist/orchestrator/reviewer.js.map +1 -0
- package/dist/orchestrator/ship.d.ts +19 -0
- package/dist/orchestrator/ship.d.ts.map +1 -0
- package/dist/orchestrator/ship.js +73 -0
- package/dist/orchestrator/ship.js.map +1 -0
- package/dist/orchestrator/sibling-context.d.ts +16 -0
- package/dist/orchestrator/sibling-context.d.ts.map +1 -0
- package/dist/orchestrator/sibling-context.js +61 -0
- package/dist/orchestrator/sibling-context.js.map +1 -0
- package/dist/orchestrator/splits.d.ts +60 -0
- package/dist/orchestrator/splits.d.ts.map +1 -0
- package/dist/orchestrator/splits.js +149 -0
- package/dist/orchestrator/splits.js.map +1 -0
- package/dist/orchestrator/status.d.ts +13 -0
- package/dist/orchestrator/status.d.ts.map +1 -0
- package/dist/orchestrator/status.js +43 -0
- package/dist/orchestrator/status.js.map +1 -0
- package/dist/orchestrator/summary.d.ts +33 -0
- package/dist/orchestrator/summary.d.ts.map +1 -0
- package/dist/orchestrator/summary.js +59 -0
- package/dist/orchestrator/summary.js.map +1 -0
- package/dist/orchestrator/sweep.d.ts +18 -0
- package/dist/orchestrator/sweep.d.ts.map +1 -0
- package/dist/orchestrator/sweep.js +79 -0
- package/dist/orchestrator/sweep.js.map +1 -0
- package/dist/orchestrator/teardown.d.ts +12 -0
- package/dist/orchestrator/teardown.d.ts.map +1 -0
- package/dist/orchestrator/teardown.js +42 -0
- package/dist/orchestrator/teardown.js.map +1 -0
- package/dist/orchestrator/worktree-cleanup.d.ts +2 -0
- package/dist/orchestrator/worktree-cleanup.d.ts.map +1 -0
- package/dist/orchestrator/worktree-cleanup.js +39 -0
- package/dist/orchestrator/worktree-cleanup.js.map +1 -0
- package/dist/prompts/implementer.md.tpl +85 -0
- package/dist/prompts/reviewer.md.tpl +118 -0
- package/dist/render-prompt.d.ts +22 -0
- package/dist/render-prompt.d.ts.map +1 -0
- package/dist/render-prompt.js +64 -0
- package/dist/render-prompt.js.map +1 -0
- package/dist/stage.d.ts +43 -0
- package/dist/stage.d.ts.map +1 -0
- package/dist/stage.js +105 -0
- package/dist/stage.js.map +1 -0
- package/docker/Dockerfile +42 -0
- package/package.json +48 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
Onion architecture, four rings. Dependencies always point inward. Lint-enforced via `eslint-plugin-boundaries`.
|
|
4
|
+
|
|
5
|
+
## The four rings
|
|
6
|
+
|
|
7
|
+
| Ring | Folder(s) | Knows about | Tests |
|
|
8
|
+
| ---------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| **Domain** | `packages/domain/` | Nothing else. Zero framework imports, zero I/O. | Pure unit tests, fast (<1s suite), property-based for state machines and math via `fast-check` |
|
|
10
|
+
| **Application** | `packages/application/` | Domain only. Defines **ports** (TS interfaces) for everything it needs from outside. | Use-case tests with in-memory port stubs (no DB, no LLM, no network) |
|
|
11
|
+
| **External** | `packages/external/{db,llm,…}` | Application + Domain. **Implements** the ports defined by Application. | Integration tests against real services where reasonable (testcontainers for databases, recorded fixtures for paid APIs) |
|
|
12
|
+
| **Presentation** | `apps/{ui,api,agent}` | All inner rings + the composition root. Wires External implementations to Application ports. | E2E with Playwright on the golden flow |
|
|
13
|
+
|
|
14
|
+
**Inner rings know nothing of outer rings.** Domain has no imports of Application; Application has no imports of External; External has no imports of Presentation. Presentation is the composition root and may import everything.
|
|
15
|
+
|
|
16
|
+
## Dependency direction is enforced, not aspired
|
|
17
|
+
|
|
18
|
+
The honor system does not work for autonomous sandcastle-drain runs. ESLint enforces:
|
|
19
|
+
|
|
20
|
+
```jsonc
|
|
21
|
+
"boundaries/element-types": ["error", {
|
|
22
|
+
"default": "disallow",
|
|
23
|
+
"rules": [
|
|
24
|
+
{ "from": "domain", "allow": ["domain"] },
|
|
25
|
+
{ "from": "application", "allow": ["domain", "application"] },
|
|
26
|
+
{ "from": "external", "allow": ["domain", "application", "external"] },
|
|
27
|
+
{ "from": "presentation", "allow": ["*"] }
|
|
28
|
+
]
|
|
29
|
+
}]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Any inner→outer import is a hard lint failure. No exemptions. If a violation feels necessary, that's a signal a port is missing in `packages/application/` — add the port, don't break the rule.
|
|
33
|
+
|
|
34
|
+
## What lives where
|
|
35
|
+
|
|
36
|
+
- **`packages/domain/aggregates/*`** — domain entities with private state, public methods, `Result<T,E>` returns, invariants enforced inside the methods. Aggregates own their child entities and value objects. Anemic-model-banned by ESLint (see [linting-and-tooling.md](linting-and-tooling.md)).
|
|
37
|
+
- **`packages/domain/dtos/*`** — transport/log/cache shapes as Zod schemas + plain functions. Anemic by design; exempt from the no-anemic-aggregate rule.
|
|
38
|
+
- **`packages/domain/value-objects/*`** — branded value objects (e.g. `Money`, `EmailAddress`, `BrandedUrl`).
|
|
39
|
+
- **`packages/application/use-cases/*`** — orchestration; one file per use case; depends on Domain and on ports it defines.
|
|
40
|
+
- **`packages/application/ports/*`** — TS interfaces describing what Application needs (a `ThingRepository`, an `LlmClient`, a `Clock`, a `Random`).
|
|
41
|
+
- **`packages/external/<adapter>/*`** — implementations of the ports. One folder per logical adapter.
|
|
42
|
+
- **`apps/api/*`** — HTTP layer (Hono or similar). Translates HTTP↔domain; runs Zod request/response schemas.
|
|
43
|
+
- **`apps/ui/*`** — React. Talks to the API. Plain TS, Zod for form validation.
|
|
44
|
+
- **`apps/agent/*`** — composition root for any agent runtime. Wires ports to External implementations. _Does not contain orchestration logic — that lives in `packages/application`._
|
|
45
|
+
|
|
46
|
+
## Where the API's layers live
|
|
47
|
+
|
|
48
|
+
A common point of confusion: looking at `apps/api/` alone, the "application layer" and "infrastructure layer" appear missing. They are not — they live in the workspace, not under `apps/api/`. The mapping for the HTTP API is:
|
|
49
|
+
|
|
50
|
+
| Onion ring | Location | What's there |
|
|
51
|
+
| -------------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| Presentation | `apps/api/routes/*.ts` | Thin route handlers. Parse request (Zod), call a use-case from `@your-project/application`, map errors via `error-mapper.ts`, return JSON. |
|
|
53
|
+
| Composition | `apps/api/composition-root.ts`, `apps/api/main.ts` | Instantiates External adapters, binds them to Application ports, hands dependency objects to use-cases. Only place concretes meet ports. |
|
|
54
|
+
| Application | `packages/application/use-cases/*` | Orchestration. One file per use-case. Takes ports as arguments; returns `Result<T, E>`. |
|
|
55
|
+
| Application | `packages/application/ports/*` | TS interfaces that use-cases depend on. |
|
|
56
|
+
| External | `packages/external/<adapter>/*` | Implementations of the ports. |
|
|
57
|
+
| Domain | `packages/domain/aggregates/*` | Aggregates with behavior. No I/O, no framework. |
|
|
58
|
+
|
|
59
|
+
If business logic appears inside `apps/api/routes/*.ts`, that's a layering bug — it belongs in a use-case under `packages/application/use-cases/`. Routes orchestrate _HTTP_; use-cases orchestrate _domain operations_. The two never collapse into one file.
|
|
60
|
+
|
|
61
|
+
### End-to-end trace: an example `POST /api/things`
|
|
62
|
+
|
|
63
|
+
1. `apps/api/routes/things.ts` — Zod-parses the request body, calls `createThing({ name, ... })`.
|
|
64
|
+
2. `packages/application/use-cases/thing/create-thing.ts` — validates inputs via domain value-object constructors, checks duplicates via `repo.loadByName()`, constructs the aggregate via `Thing.create()`, persists via `repo.save()`. Returns `Result<ThingSnapshot, CreateThingError>`.
|
|
65
|
+
3. `packages/domain/aggregates/thing/thing.ts` — `Thing.create()` enforces invariants and returns the aggregate.
|
|
66
|
+
4. `packages/external/thing-store-<backend>/thing-repository.ts` — `save()` writes the underlying store.
|
|
67
|
+
5. `apps/api/error-mapper.ts` — maps any `Err` variant from step 2 to an HTTP status code.
|
|
68
|
+
6. `apps/api/routes/things.ts` — serializes the success snapshot to JSON.
|
|
69
|
+
|
|
70
|
+
Each step lives in its named ring. No step reaches across rings except via the explicit dependency injection at `composition-root.ts`.
|
|
71
|
+
|
|
72
|
+
## Folder scaffolding is created lazily
|
|
73
|
+
|
|
74
|
+
This principles doc describes what these folders _will be_. The folders themselves are created when the first product issue needs them — not pre-created here. Pre-creating empty folders with placeholder code lies about what the project knows.
|
|
75
|
+
|
|
76
|
+
When the first product issue lands and the agent needs `packages/domain/`, it scaffolds:
|
|
77
|
+
|
|
78
|
+
- `packages/domain/package.json` (workspace member)
|
|
79
|
+
- `packages/domain/tsconfig.json` (extends root with `composite: true`)
|
|
80
|
+
- `packages/domain/index.ts` (just exports)
|
|
81
|
+
- The aggregate / dto / value-object subfolders only as needed
|
|
82
|
+
|
|
83
|
+
Same pattern for `packages/application/`, `packages/external/<adapter>/`, and the apps.
|
|
84
|
+
|
|
85
|
+
## Ports define the dependency _inversion_
|
|
86
|
+
|
|
87
|
+
The Application layer defines what it needs as TypeScript interfaces. External adapters implement those interfaces. This is the heart of why Domain stays pure: it never imports a database driver, an HTTP client, or an LLM SDK.
|
|
88
|
+
|
|
89
|
+
Example shape:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// packages/application/ports/thing-repository.ts
|
|
93
|
+
import type { Thing, ThingId } from '../../domain/index.ts';
|
|
94
|
+
|
|
95
|
+
export interface ThingRepository {
|
|
96
|
+
save(thing: Thing): Promise<Result<ThingId, RepositoryError>>;
|
|
97
|
+
load(id: ThingId): Promise<Result<Thing, RepositoryError>>;
|
|
98
|
+
listByOwner(ownerId: OwnerId): Promise<Result<readonly Thing[], RepositoryError>>;
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The External adapter (`packages/external/thing-store-<backend>/`) implements `ThingRepository` against actual storage. The Application use-case takes `ThingRepository` by constructor injection or function argument. Tests use an in-memory implementation.
|
|
103
|
+
|
|
104
|
+
## Composition root
|
|
105
|
+
|
|
106
|
+
`apps/agent` (and `apps/api`) wire the External implementations to the Application ports at startup. This is the _only_ place concrete implementations are bound to interfaces — every other layer takes ports as dependencies.
|
|
107
|
+
|
|
108
|
+
No service locators, no singletons, no module-level mutable state. Dependencies are explicit, passed in, and replaceable at the composition root.
|
|
109
|
+
|
|
110
|
+
## SOLID, named against this onion
|
|
111
|
+
|
|
112
|
+
The onion already encodes SOLID; this section attaches the names so reviews can refer to them. Each principle here is _grounded in a structural pattern of this codebase_, not taught generically.
|
|
113
|
+
|
|
114
|
+
**S — Single Responsibility.** One reason to change per module. Aggregates own invariants for one concept; use-cases orchestrate one workflow each (`packages/application/use-cases/*`); adapters wrap one external service each (`packages/external/<adapter>/`). Enforced structurally by the ring/folder layout above and the custom `local/no-anemic-aggregate` rule in [linting-and-tooling.md](linting-and-tooling.md) — a class doing nothing but holding data is structurally a DTO, not an aggregate.
|
|
115
|
+
|
|
116
|
+
**O — Open/Closed.** New behavior arrives as new files, not by editing existing ones. New aggregate kind → new folder under `packages/domain/aggregates/`. New external service → new folder under `packages/external/`. New port → new file under `packages/application/ports/`. The ring rules (above) refuse the alternative — you cannot extend a domain aggregate by reaching into it from External, you must add a port and a use-case.
|
|
117
|
+
|
|
118
|
+
**L — Liskov Substitution.** Every adapter under `packages/external/<adapter>/` is fully substitutable for its `packages/application/ports/*` interface. The in-memory test double and any production implementation must all satisfy the same contract — including failure modes (`Result<T, RepositoryError>`, see [language-and-types.md](language-and-types.md)). A port whose real implementation throws where the test double returns `Result` is a Liskov violation; fix by promoting the failure into the port's `E` type.
|
|
119
|
+
|
|
120
|
+
**I — Interface Segregation.** Ports describe what a _single_ use-case needs, not a kitchen-sink "data access object." If a use-case only reads, its port has only the read method — it does not depend on a larger repository that also writes. The Reified Association pattern in [domain-modeling.md](domain-modeling.md) reinforces this at the schema level (link entities are their own aggregates with their own ports). When two use-cases share an adapter, they import two narrow ports that the adapter happens to implement together — never one fat port.
|
|
121
|
+
|
|
122
|
+
**D — Dependency Inversion.** Application defines the port (an abstraction); External implements it (a detail). Domain and Application never import External. The composition root in `apps/agent` and `apps/api` is the _only_ place concrete adapters bind to ports — every other layer takes ports as constructor or function arguments. This is enforced by `eslint-plugin-boundaries` (see config above) — the rule forbids inner-from-outer imports outright. Zod parsing at every boundary (see [language-and-types.md](language-and-types.md)) is the runtime complement: the abstraction promises a shape, the boundary parses to confirm.
|
|
123
|
+
|
|
124
|
+
The naming buys nothing at the implementation level — the rules and rings already exist. It buys vocabulary: a review comment "this violates ISP — the port has read and write methods, the use-case only reads" is faster than re-deriving the principle from first principles each time.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Claude Code modes
|
|
2
|
+
|
|
3
|
+
Two Claude Code modes operate in this repo:
|
|
4
|
+
|
|
5
|
+
- **Interactive** — you and Claude Code at a terminal/IDE, full conversation, real-time corrections.
|
|
6
|
+
- **Autonomous (sandcastle-drain)** — Claude Code drains GitHub issues unattended via [src/orchestrator/main.ts](../../orchestrator/main.ts), one issue at a time, no human in the loop.
|
|
7
|
+
|
|
8
|
+
Most rules apply to both. A handful of rules tighten in autonomous mode because there is no user to correct mid-run. This file captures both the universal set and the autonomous-only deltas.
|
|
9
|
+
|
|
10
|
+
## Universal rules (apply to both modes)
|
|
11
|
+
|
|
12
|
+
These are the non-negotiables Claude Code follows in either mode. Most are restated from elsewhere; this is the consolidated checklist.
|
|
13
|
+
|
|
14
|
+
- **Architecture, typing, lint, testing.** Everything in [architecture.md](architecture.md), [language-and-types.md](language-and-types.md), [linting-and-tooling.md](linting-and-tooling.md), [testing.md](testing.md). All of it. No exemptions.
|
|
15
|
+
- **Nomenclature binding.** Every type / table / file path / UI label uses the names defined in [CONTEXT.md](../../../CONTEXT.md). New domain concepts go in CONTEXT.md (via `grill-with-docs`) before code uses them. See [domain-modeling.md](domain-modeling.md).
|
|
16
|
+
- **No `--no-verify`.** Pre-commit hooks run on every commit. If a hook fails, fix the root cause; do not bypass.
|
|
17
|
+
- **No `git push` / `gh pr create` from inside a Sandcastle container.** (Already enforced in [src/prompts/implementer.md.tpl](../../prompts/implementer.md.tpl) + the wrapper's defensive check; restated here because it's load-bearing.)
|
|
18
|
+
- **Personal-use trade-offs apply to both modes.** Don't over-engineer UI/auth/observability; do not skimp on domain correctness, types, or backups. See [personal-use-tradeoffs.md](personal-use-tradeoffs.md).
|
|
19
|
+
|
|
20
|
+
## Autonomous-only deltas
|
|
21
|
+
|
|
22
|
+
These rules tighten when Claude Code runs unattended via sandcastle-drain.
|
|
23
|
+
|
|
24
|
+
| Behavior | Interactive | Autonomous |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| **Token budget self-monitoring** | Guidance — you can see context fill | **Hard rule** — mechanical post-run measurement, between-iteration abort. See [context-budget.md](context-budget.md). |
|
|
27
|
+
| **Summarize-don't-paste tool output** | Guidance | **Hard rule** — summarize relevant lines before next reasoning step unless byte-exact content is needed |
|
|
28
|
+
| **Asking for clarification** | Free to ask | **Forbidden** — emit `<promise>COMPLETE</promise>` with the question instead. The wrapper labels the issue `needs-info`. |
|
|
29
|
+
| **`git push` / `gh pr create`** | Confirm with user (per global CLAUDE.md) | **Forbidden** — wrapper defensively checks and warns if the agent did it anyway |
|
|
30
|
+
| **Running tests before commit** | Guidance — can skip on user's say-so | **Hard rule** — when introducing testable behavior, tests run before commit. No soft-skip. |
|
|
31
|
+
| **Risky / destructive actions** | Confirm with user | **Don't take them.** No `git reset --hard`, no force-push, no destructive shell commands |
|
|
32
|
+
|
|
33
|
+
## What to do when an issue conflicts with these rules
|
|
34
|
+
|
|
35
|
+
If a Sandcastle-labeled issue asks Claude Code to do something these rules forbid (e.g. "push the branch and open a PR"), the right move is:
|
|
36
|
+
|
|
37
|
+
1. **Don't do the forbidden action.** Even if the issue body explicitly asks.
|
|
38
|
+
2. **Make whatever code changes the issue actually needs** that aren't forbidden.
|
|
39
|
+
3. **Emit `<promise>COMPLETE</promise>`** with a paragraph explaining: which part of the issue you completed, which part requires the human, and what the issue should be split into.
|
|
40
|
+
4. The wrapper labels the issue `needs-review` (because there are commits) or `needs-info` (if there were no commits). The user re-scopes from there.
|
|
41
|
+
|
|
42
|
+
Better to half-deliver an over-scoped issue than to silently break the rules. The rules exist for the project's safety, not to be worked around.
|
|
43
|
+
|
|
44
|
+
## Reading this set
|
|
45
|
+
|
|
46
|
+
- Interactive mode: read [README.md](README.md) and the file relevant to what you're doing. Most rules are universal.
|
|
47
|
+
- Autonomous (sandcastle-drain): the wrapper's prompt at [src/prompts/implementer.md.tpl](../../prompts/implementer.md.tpl) points here. Always read this file in addition to whatever the issue's work requires.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Clean code
|
|
2
|
+
|
|
3
|
+
Four rules that don't fit anywhere else in the principles folder. Each section: definition, why-it-matters-here, enforcement-or-convention, link to the related file.
|
|
4
|
+
|
|
5
|
+
These overlap with SOLID (see [architecture.md](architecture.md)) but operate at the level of individual functions and small modules — SOLID describes _how rings and modules relate_; this file describes _what a single function looks like_.
|
|
6
|
+
|
|
7
|
+
## DRY / YAGNI / KISS
|
|
8
|
+
|
|
9
|
+
**DRY** — Don't Repeat Yourself. Two copies of the same domain rule are two places to update when the rule changes; one will be missed.
|
|
10
|
+
|
|
11
|
+
**YAGNI** — You Aren't Gonna Need It. Don't add config knobs, plugin points, or generic abstractions for a second use-case that doesn't yet exist.
|
|
12
|
+
|
|
13
|
+
**KISS** — Keep It Simple, Stupid. The simpler shape that satisfies the current requirement wins, even when a fancier shape would be defensible.
|
|
14
|
+
|
|
15
|
+
### Why these matter on this project specifically
|
|
16
|
+
|
|
17
|
+
This is a personal-use, single-operator project (see [personal-use-tradeoffs.md](personal-use-tradeoffs.md)) being built partially by autonomous agents. Two failure modes loom:
|
|
18
|
+
|
|
19
|
+
- **Premature abstraction by the agent.** Agents trained on enterprise codebases will reach for plugin systems, dependency-injection containers, and config-driven dispatch on the second example of a pattern. We have one user, one deployment, one of each external service. YAGNI hard.
|
|
20
|
+
- **Duplicated domain rules.** A re-derivation of a transition rule or a derived-value formula in two files is a correctness landmine — the system silently disagrees with itself. DRY hard _for domain logic_.
|
|
21
|
+
|
|
22
|
+
### Enforcement / convention
|
|
23
|
+
|
|
24
|
+
- **Domain rules live in exactly one place** — the aggregate method that owns the invariant. Use-cases call it; renderers call it; tests assert on it. Re-deriving the rule elsewhere is a review-blocker.
|
|
25
|
+
- **Boilerplate duplication (e.g. similar Zod schema shape across DTOs) is fine** — the cost of a wrong abstraction is higher than the cost of two parallel schemas. DRY applies to _meaning_, not syntax.
|
|
26
|
+
- **No config-driven branching for hypothetical second cases.** If the project later grows a second LLM provider, second search backend, or second persistence target, _that_ is when the abstraction is added — driven by the second concrete case, not predicted.
|
|
27
|
+
|
|
28
|
+
Related: [personal-use-tradeoffs.md](personal-use-tradeoffs.md) (the relaxed column is YAGNI by another name), [domain-modeling.md](domain-modeling.md) (the ceremony rule is KISS — only DDD where it pays).
|
|
29
|
+
|
|
30
|
+
## Small focused functions, low nesting
|
|
31
|
+
|
|
32
|
+
A function does one thing. Branches stay shallow. If a function needs three levels of indentation to express its logic, the inner branches are usually a separate function waiting to be extracted.
|
|
33
|
+
|
|
34
|
+
### Why on this project
|
|
35
|
+
|
|
36
|
+
- **Pure-function unit tests stay cheap** when each function does one thing. A function that fans into four nested ifs has 16 paths; coverage gates (90% line + branch in the domain layer, see [testing.md](testing.md)) start lying.
|
|
37
|
+
- **Agent code review benefits from short functions.** A reviewer (human or agent) can hold a 15-line function in working memory and verify it. A 60-line function with nested control flow gets skimmed.
|
|
38
|
+
- **Property-based tests (`fast-check`, see [testing.md](testing.md)) shrink poorly** when the unit-under-test mixes side-effects with computation. Small pure functions shrink to minimal failing cases.
|
|
39
|
+
|
|
40
|
+
### Enforcement (lint config)
|
|
41
|
+
|
|
42
|
+
ESLint core rules added to [linting-and-tooling.md](linting-and-tooling.md)'s "Specific rules" block:
|
|
43
|
+
|
|
44
|
+
```jsonc
|
|
45
|
+
{
|
|
46
|
+
"max-depth": ["error", 3],
|
|
47
|
+
"complexity": ["error", 10],
|
|
48
|
+
"max-lines-per-function": ["error", { "max": 50, "skipBlankLines": true, "skipComments": true }],
|
|
49
|
+
"max-params": ["error", 4],
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`max-depth: 3` is generous enough for a sane `if / for / try` nesting and tight enough to flag a fourth level. `complexity: 10` (cyclomatic) catches over-branched switches without flagging legitimate state-machine handlers — a typical aggregate's transition switch sits comfortably under 10. `max-lines-per-function: 50` is loose for orchestration in the application layer and tight enough that a domain method past 50 lines is suspicious. `max-params: 4` pushes overflow into a parameter object, which makes the call-site readable and improves Zod-schema-at-the-boundary ergonomics.
|
|
54
|
+
|
|
55
|
+
These thresholds are starting points; raise a specific rule for a specific function via `// eslint-disable-next-line` _with a comment explaining why_, never globally.
|
|
56
|
+
|
|
57
|
+
## Composition over inheritance
|
|
58
|
+
|
|
59
|
+
No `extends` between domain classes. Behavior is shared by composing aggregates, value objects, and pure functions — not by class hierarchies.
|
|
60
|
+
|
|
61
|
+
### Why on this project
|
|
62
|
+
|
|
63
|
+
The onion (see [architecture.md](architecture.md)) already gives us composition as the substitution mechanism: the Application layer composes a use-case from a port (interface) and a handful of pure domain functions, then the composition root (`apps/agent`, `apps/api`) wires a concrete adapter to the port. There is no role left for class inheritance to play, and adding one would create a parallel substitution mechanism that fights the port pattern.
|
|
64
|
+
|
|
65
|
+
Concretely:
|
|
66
|
+
|
|
67
|
+
- **Domain aggregates are stand-alone classes**, not subclasses. Each aggregate (see [domain-modeling.md](domain-modeling.md)) owns its state and methods directly.
|
|
68
|
+
- **Shared behavior across aggregates** — e.g. an append-only versioning pattern — is a function in `packages/domain/` that aggregates _call_, not a base class they extend.
|
|
69
|
+
- **Polymorphism uses tagged unions**, not subtyping. Status types are discriminated unions; new variants are new tags, exhaustively checked by `noFallthroughCasesInSwitch` and `@typescript-eslint/switch-exhaustiveness-check` (see [language-and-types.md](language-and-types.md)).
|
|
70
|
+
- **Adapters implement ports**; they do not extend a base adapter. If two adapters share helper logic, the helper is a function they both import, not a parent class they both subclass.
|
|
71
|
+
|
|
72
|
+
### Convention
|
|
73
|
+
|
|
74
|
+
No ESLint rule enforces this directly — the `functional` plugin from [linting-and-tooling.md](linting-and-tooling.md) discourages classes broadly. The structural enforcement is the ring layout: there is nowhere for an inheritance hierarchy to root that wouldn't either duplicate the port pattern (in Application) or import inward (in External).
|
|
75
|
+
|
|
76
|
+
## Pure functions and immutability — domain layer only
|
|
77
|
+
|
|
78
|
+
Functions in `packages/domain/` are pure: same inputs → same outputs, no I/O, no time, no randomness. State they mutate is their own argument, returned, never an external store. Data structures passed across function boundaries are `readonly`.
|
|
79
|
+
|
|
80
|
+
### Why on this project
|
|
81
|
+
|
|
82
|
+
- **Domain methods are tested by `fast-check` property-based tests** (see [testing.md](testing.md)). Property-based testing requires referential transparency — a function that reads `Date.now()` or a global cache cannot be shrunk to a minimal failing case.
|
|
83
|
+
- **Domain functions are called from multiple call sites** — the use-case that performs the transition, the renderer that previews the next state, the test suite. If the function had I/O, the renderer would either stub the I/O or perform real I/O during a render — both are failure modes.
|
|
84
|
+
- **`Clock` and `Random` are ports**, not direct imports (see [architecture.md](architecture.md)'s ports section). The current time and a random seed enter the domain as _arguments_, never as ambient calls.
|
|
85
|
+
|
|
86
|
+
### Scope: domain only
|
|
87
|
+
|
|
88
|
+
This rule scopes strictly to `packages/domain/`. The other rings _must_ have side effects:
|
|
89
|
+
|
|
90
|
+
- **Application** orchestrates I/O via ports — that's its job.
|
|
91
|
+
- **External** performs the actual I/O.
|
|
92
|
+
- **Presentation** holds React state, browser-side mutability, network calls.
|
|
93
|
+
|
|
94
|
+
The `functional` ESLint plugin (see [linting-and-tooling.md](linting-and-tooling.md)) is configured to apply its strict immutability rules _only inside `packages/domain/`_ — applying them globally would fight the necessary mutability of UI and adapters.
|
|
95
|
+
|
|
96
|
+
### Enforcement
|
|
97
|
+
|
|
98
|
+
- `local/no-throw-in-domain` — already exists, see [linting-and-tooling.md](linting-and-tooling.md).
|
|
99
|
+
- `functional/no-let`, `functional/immutable-data`, `functional/no-this-expressions` — apply with `files: ["packages/domain/**"]` override in the ESLint config.
|
|
100
|
+
- `readonly` keyword on every aggregate field, every value-object property, every parameter array. Already implied by the existing aggregate examples in [domain-modeling.md](domain-modeling.md).
|
|
101
|
+
|
|
102
|
+
A pure-function rule cannot be fully lint-enforced (a hand-rolled `Math.random()` call is syntactically fine), so this is partly convention. Code review and property-based tests catch what lint cannot.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Context budget (sandcastle-drain / autonomous mode)
|
|
2
|
+
|
|
3
|
+
This is the AI-specific principle. It applies hardest in autonomous sandcastle-drain runs and as guidance interactively.
|
|
4
|
+
|
|
5
|
+
The "smart zone" of an LLM context window is well below the technical ceiling. Anthropic's needle-in-a-haystack research shows retrieval accuracy degrades as context fills, with significant drop-off past ~50–60% fill on Claude models. Empirical agentic-loop quality degrades earlier still — most practitioners observe meaningful degradation around 100–200k cumulative usage including tool outputs.
|
|
6
|
+
|
|
7
|
+
Working in the smart zone means: **keep working contexts small, eject when they grow.**
|
|
8
|
+
|
|
9
|
+
## The numbers
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// src/orchestrator/config.ts (queued as a follow-up issue)
|
|
13
|
+
export const BUDGET = {
|
|
14
|
+
target: 100_000, // info threshold
|
|
15
|
+
ceiling: 150_000, // hard ceiling — split work, do not retry
|
|
16
|
+
} as const;
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Tweak these by editing one file. Single source of truth.
|
|
20
|
+
|
|
21
|
+
## Issue sizing (when filing or accepting `sandcastle` work)
|
|
22
|
+
|
|
23
|
+
An issue is "right-sized" if it can be completed in one sandcastle-drain run ending under the **150k ceiling** of cumulative context (issue body + tool outputs + agent thinking + commits + final summary).
|
|
24
|
+
|
|
25
|
+
Useful proxy heuristics:
|
|
26
|
+
- The agent needs to read more than ~5 files
|
|
27
|
+
- The agent needs to write more than ~3 files
|
|
28
|
+
- The agent needs more than ~10 tool calls in research
|
|
29
|
+
|
|
30
|
+
If any of those, the issue is probably too big. Split it via `to-issues` before queueing.
|
|
31
|
+
|
|
32
|
+
## Mechanical post-run measurement
|
|
33
|
+
|
|
34
|
+
The wrapper at [src/orchestrator/main.ts](../../orchestrator/main.ts) measures every run via Sandcastle 0.5.7's built-in `IterationUsage`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const total = result.iterations.reduce((sum, it) => {
|
|
38
|
+
const u = it.usage;
|
|
39
|
+
return sum + u.inputTokens + u.cacheCreationInputTokens + u.cacheReadInputTokens;
|
|
40
|
+
}, 0);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Action by threshold:
|
|
44
|
+
- **Cumulative > 100k (target)** → status comment includes `"context: <N>k (near budget)"`. No label change.
|
|
45
|
+
- **Cumulative > 150k (ceiling)** → label `oversized` added; wrapper logs a recommendation to split via `to-issues`.
|
|
46
|
+
|
|
47
|
+
## Between-iteration abort
|
|
48
|
+
|
|
49
|
+
When `maxIterations > 1`, the wrapper checks the running total at iteration boundaries. If the next iteration would push past the **150k ceiling**, abort early and let the agent emit a `<promise>COMPLETE</promise>` with what it accomplished.
|
|
50
|
+
|
|
51
|
+
## Mid-iteration: trust the agent's discipline
|
|
52
|
+
|
|
53
|
+
Mid-iteration token usage is unmeasured in this rig. The compensating discipline is **summarize-don't-paste** (below) plus the agent's own awareness that long tool outputs compound the next reasoning step's cost.
|
|
54
|
+
|
|
55
|
+
## Summarize-don't-paste
|
|
56
|
+
|
|
57
|
+
> **Hard rule for autonomous sandcastle-drain runs.** When tool output isn't needed verbatim downstream, summarize the relevant 3 lines instead of pasting the full output into the next reasoning step.
|
|
58
|
+
>
|
|
59
|
+
> **Guidance for interactive sessions.** The user can see verbose output and react; mechanical compaction matters less.
|
|
60
|
+
|
|
61
|
+
Concretely:
|
|
62
|
+
- After a `Read` of a long file, write one or two sentences about what's relevant before continuing — don't reason against the entire file in your next thought.
|
|
63
|
+
- After a `Bash` of `git log`, summarize the relevant commits — don't quote the output back into context unnecessarily.
|
|
64
|
+
- After a long `gh issue view`, restate the question in two lines.
|
|
65
|
+
|
|
66
|
+
The goal isn't to produce shorter responses to the user. It's to keep the *thinking* section of subsequent steps from carrying unrelated tool output.
|
|
67
|
+
|
|
68
|
+
## When to commit-and-eject
|
|
69
|
+
|
|
70
|
+
If the agent notices mid-run that the remaining work won't fit under 150k:
|
|
71
|
+
1. Commit what's already complete (one logical unit at a time).
|
|
72
|
+
2. Emit `<promise>COMPLETE</promise>` with a "needs split into A and B" note.
|
|
73
|
+
3. The wrapper labels the issue `needs-info`; the user re-files split issues from the note.
|
|
74
|
+
|
|
75
|
+
Do not half-do the rest. A half-finished task that runs over budget produces low-quality output that the next agent run can't easily salvage.
|
|
76
|
+
|
|
77
|
+
## Retrieval, not regurgitation
|
|
78
|
+
|
|
79
|
+
Any persistent store (database, wiki, knowledge base) *exists* to keep working context small. When an agent reads from one, the right shape is **read just enough to do the task**, not dump the whole store into context. Index-first retrieval (read an index, drill into specific pages, query for raw quotes only when needed) outperforms embedding-first scattershot recall.
|
|
80
|
+
|
|
81
|
+
If you build a memory / retrieval system on top of this template, document its shape in an ADR and a per-project principle file — the discipline above (100k / 150k mechanical measurement, summarize-don't-paste) is what creates the pressure to design retrieval well.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# CQRS
|
|
2
|
+
|
|
3
|
+
Command-Query Responsibility Segregation, applied at the Application port boundary. Reads and writes for an aggregate go through separate ports, implemented by separate adapters (or one adapter exposing both). Domain stays unaware.
|
|
4
|
+
|
|
5
|
+
This composes with Onion (see [architecture.md](architecture.md)); it does not replace it. The onion describes _which ring code lives in_; CQRS describes _what shape the ports in `packages/application/ports/` take_.
|
|
6
|
+
|
|
7
|
+
## Rule
|
|
8
|
+
|
|
9
|
+
For each aggregate, the Application layer defines two ports:
|
|
10
|
+
|
|
11
|
+
- **`<Aggregate>CommandRepository`** — writes only. `save`, `delete`, `apply<Event>`. Returns `Result<Unit | <Aggregate>Snapshot, RepositoryError>`.
|
|
12
|
+
- **`<Aggregate>QueryRepository`** — reads only. `loadById`, `loadBy<NaturalKey>`, `listBy*`, projected views. Returns `Result<<ReadModel>, RepositoryError>`.
|
|
13
|
+
|
|
14
|
+
Use-cases compose one or both as needed. A use-case that only reads imports only the query port; a use-case that only writes imports only the command port. A use-case that does both takes both (and is a candidate for splitting — see anti-patterns below).
|
|
15
|
+
|
|
16
|
+
Adapters in `packages/external/<adapter-name>/` may share storage and even share a class internally — but the file they export must declare two interface implementations, never a single union of methods.
|
|
17
|
+
|
|
18
|
+
## Why this matters
|
|
19
|
+
|
|
20
|
+
The structural payoff is concrete:
|
|
21
|
+
|
|
22
|
+
- A read-path's signature cannot accidentally introduce a write, because its port has no write method.
|
|
23
|
+
- A projection-only read can return whatever shape best fits the caller (denormalized view, joined snapshot, aggregated count) without forcing the write model to grow methods that serve it.
|
|
24
|
+
- The Liskov substitution rule from [architecture.md](architecture.md) ("every adapter is fully substitutable for its port") tightens — a real adapter and an in-memory test double satisfy the _same_ narrow query interface, with no write methods to drift on.
|
|
25
|
+
- If your persistence model is append-only (audit aggregates, event-sourced state), CQRS at the port layer mirrors that asymmetry at the code level. A single combined repository ends up carrying both `appendX` and `listX`, and the use-case signature stops telling the reader whether this is a write-path or a read-path.
|
|
26
|
+
|
|
27
|
+
## How it composes with Onion
|
|
28
|
+
|
|
29
|
+
- **Domain knows nothing of CQRS.** No `<Aggregate>Command` types. No `<Aggregate>Query` types. Aggregates have their behavior methods (`Thing.archive()`, `Order.ship()`); that's it. CQRS lives at the port boundary, not in the domain language. [CONTEXT.md](../../CONTEXT.md) does not need a "command" or "query" entry.
|
|
30
|
+
- **Application defines the two ports.** Ports live in `packages/application/ports/<aggregate>-command-repository.ts` and `packages/application/ports/<aggregate>-query-repository.ts`. Use-cases import only what they need.
|
|
31
|
+
- **External implements them.** An adapter in `packages/external/<adapter-name>/` may expose both interfaces from one file. The implementation can share a connection pool, transaction handling, and helpers freely — splitting is at the _interface_, not the _runtime_.
|
|
32
|
+
- **The composition root in `apps/<app>/composition-root.ts` wires both.** Same place as today; just two bindings per aggregate instead of one.
|
|
33
|
+
|
|
34
|
+
Example port shapes:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// packages/application/ports/order-command-repository.ts
|
|
38
|
+
import type { Order, OrderId } from '@your-project/domain';
|
|
39
|
+
import type { Result } from '@your-project/domain';
|
|
40
|
+
|
|
41
|
+
export interface OrderCommandRepository {
|
|
42
|
+
save(order: Order): Promise<Result<OrderId, RepositoryError>>;
|
|
43
|
+
applyTransition(id: OrderId, event: OrderEvent): Promise<Result<Unit, RepositoryError>>;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// packages/application/ports/order-query-repository.ts
|
|
49
|
+
import type { OrderId, OrderSnapshot, CustomerId } from '@your-project/domain';
|
|
50
|
+
import type { Result } from '@your-project/domain';
|
|
51
|
+
|
|
52
|
+
export interface OrderQueryRepository {
|
|
53
|
+
loadById(id: OrderId): Promise<Result<OrderSnapshot, RepositoryError>>;
|
|
54
|
+
listForCustomer(customerId: CustomerId): Promise<Result<readonly OrderSnapshot[], RepositoryError>>;
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Anti-patterns
|
|
59
|
+
|
|
60
|
+
- **`<Aggregate>Command` / `<Aggregate>Query` types in `packages/domain/`.** CQRS is a port-boundary pattern, not a domain-modeling pattern. Domain stays in the vocabulary of [CONTEXT.md](../../CONTEXT.md) — not Commands and Queries.
|
|
61
|
+
- **Splitting ports without splitting use-cases.** If a use-case takes both `<Aggregate>CommandRepository` and `<Aggregate>QueryRepository` and uses them interleaved, the separation buys nothing. Either split the use-case (the read piece is a query use-case, the write piece is a command use-case) or accept that this specific use-case is intrinsically mixed and document why.
|
|
62
|
+
- **Duplicating a shared snapshot DTO into command-side and query-side variants when the shape is identical.** Reuse the snapshot from `packages/domain/dtos/`. Split only when read needs genuinely diverge (e.g., the query side wants a denormalized view with joined data).
|
|
63
|
+
- **Putting query ports in `packages/external/<adapter>/` and command ports in `packages/application/ports/`.** Both ports live in Application — they describe what Application needs. External implements both.
|
|
64
|
+
- **Sneaking writes into query methods.** A `loadById` that lazily backfills cache rows is a write disguised as a read. The cache-miss case still goes through the command path or stays out of the port entirely (handled by the adapter internally, transparent to the use-case).
|
|
65
|
+
|
|
66
|
+
## Relation to other principles
|
|
67
|
+
|
|
68
|
+
- [architecture.md](architecture.md) — onion rings, port pattern, dependency inversion. CQRS narrows what a port looks like; the ring rules are unchanged.
|
|
69
|
+
- [domain-modeling.md](domain-modeling.md) — aggregates, value objects, reified-association pattern. None of these require CQRS knowledge; CQRS does not change how the domain expresses itself.
|
|
70
|
+
- [testing.md](testing.md) — port-stub tests in Application now use two stubs instead of one. The cost is one extra file per use-case test; the win is that command stubs never accidentally serve reads and vice versa.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Domain modeling
|
|
2
|
+
|
|
3
|
+
How to decide whether a new entity gets full DDD ceremony or just a Zod schema. How to name things. How to model relationships that have their own attributes.
|
|
4
|
+
|
|
5
|
+
## The ceremony rule (wrong-if-violated)
|
|
6
|
+
|
|
7
|
+
An entity gets `packages/domain/aggregates/` treatment — class with private state, public methods, `Result<T,E>` returns, repository port — when **any** of these are true:
|
|
8
|
+
|
|
9
|
+
1. **Lifecycle states with rules about which transitions are legal.** E.g. an `Order` going `placed → shipped`, never directly `placed → delivered` without a `shipped` event in between.
|
|
10
|
+
2. **Composes other domain objects under invariants.** E.g. an aggregate that requires every child entity to satisfy a structural constraint before the parent is valid.
|
|
11
|
+
3. **Computed fields whose correctness depends on inputs the type system can't validate.** E.g. financial math, completeness checks, derived totals.
|
|
12
|
+
4. **Misuse causes silent wrong answers, not just crashes.** This is the operational test: *would silent corruption be possible if we used a plain shape here?* If yes, DDD.
|
|
13
|
+
|
|
14
|
+
If none of these apply, **use a Zod schema in `packages/domain/dtos/` plus plain functions.** Examples that go in `dtos/`: chat messages, tool-call traces, search-result caches, config, UI form state. These are transport/log/cache shapes — anemic by design and exempt from the no-anemic-aggregate rule.
|
|
15
|
+
|
|
16
|
+
## Anemic models banned in `aggregates/`
|
|
17
|
+
|
|
18
|
+
A custom ESLint rule (`local/no-anemic-aggregate`, see [linting-and-tooling.md](linting-and-tooling.md)) fails any class exported from `packages/domain/aggregates/` that has only a constructor and getters with no behavioral methods. Aggregates carry their invariants *inside* themselves; a class that's just a data bag belongs in `dtos/`, not `aggregates/`.
|
|
19
|
+
|
|
20
|
+
DTOs in `packages/domain/dtos/` are explicitly exempt — they are *supposed* to be data bags.
|
|
21
|
+
|
|
22
|
+
## Reified-Association pattern
|
|
23
|
+
|
|
24
|
+
When a relationship between two aggregates has its own attributes and its own lifecycle, model it as a **Reified Association** (sometimes called Associative Entity or Link Entity) — its own aggregate root, with its own repository port.
|
|
25
|
+
|
|
26
|
+
Symptoms that point to this pattern:
|
|
27
|
+
|
|
28
|
+
- The relationship itself has attributes (e.g. role, weight, validity window, last-verified-at).
|
|
29
|
+
- The relationship can be revised independently of either end (e.g. update the role, mark it expired).
|
|
30
|
+
- The relationship can be queried as a fleet (e.g. "all approvals for document X with status `expired`").
|
|
31
|
+
|
|
32
|
+
Concretely, given two aggregates `A` and `B` whose link has its own data and lifecycle:
|
|
33
|
+
|
|
34
|
+
- Create an aggregate `AbLink` (or whatever the canonical name in `CONTEXT.md` is).
|
|
35
|
+
- It is an Entity, **its own aggregate root**.
|
|
36
|
+
- It has methods that mutate its own attributes (`revise(...)`, `expire(reason)`, etc.) — not setters.
|
|
37
|
+
- Cross-aggregate references via IDs (`aId: AId`, `bId: BId`), never direct object references.
|
|
38
|
+
- Has its own repository port in `packages/application/ports/`.
|
|
39
|
+
|
|
40
|
+
A bare `bId: BId` field on `A` is the wrong shape when the link has data of its own — that flattens the association into a foreign-key and loses the place where the relationship's attributes and lifecycle live.
|
|
41
|
+
|
|
42
|
+
## Decomposed display values (no premature scalar collapse)
|
|
43
|
+
|
|
44
|
+
When a user-facing value summarises several independent inputs (e.g. an overall confidence, a risk score, a recommendation rank), prefer keeping the constituent values addressable rather than collapsing them into a single stored scalar.
|
|
45
|
+
|
|
46
|
+
The default: render the components side-by-side at read time, and compute any composed sort key as a **pure domain function** that has no persisted value backing it. The composed value is a function of the constituents, not a field on any aggregate.
|
|
47
|
+
|
|
48
|
+
This avoids the silent-staleness failure mode where the stored scalar diverges from the source values after a related field is revised. ADR the formula when one is first needed — including which constituent fields feed it and what the function is. See [architecture.md](architecture.md) for where pure functions live.
|
|
49
|
+
|
|
50
|
+
## Nomenclature: CONTEXT.md is the canonical vocabulary
|
|
51
|
+
|
|
52
|
+
> **CONTEXT.md is the single source of truth for domain vocabulary.** Every type, table, file path, and UI label uses the names defined there without aliasing. New domain concepts are added to CONTEXT.md **before** code uses them.
|
|
53
|
+
|
|
54
|
+
If CONTEXT.md defines a concept as e.g. "Customer Order," the class is `CustomerOrder`, the database table is `customer_order`, the file folder is `customer-order/`, the UI label is "Customer Order". One vocabulary, four representations, no aliases.
|
|
55
|
+
|
|
56
|
+
A custom ESLint rule (`local/domain-names-match-context-md`) parses CONTEXT.md headings and fails any export from `packages/domain/aggregates/` or `packages/domain/value-objects/` whose name doesn't match an entry. This rule is silent until CONTEXT.md has been populated.
|
|
57
|
+
|
|
58
|
+
ADRs in `docs/adr/` record *why* a name was chosen. If a rename is justified, it lives in an ADR and the rename happens atomically across CONTEXT.md, code, tables, files, and UI.
|
|
59
|
+
|
|
60
|
+
## Where domain logic actually runs
|
|
61
|
+
|
|
62
|
+
Domain methods are pure: same inputs, same outputs, no I/O. Side effects (saving an aggregate after a transition) are orchestrated by the **Application layer's use cases** which load the aggregate via a repository port, mutate it via its own methods, and save it back. The aggregate's transition method *does not* know there is a database. See [architecture.md](architecture.md) for the layer rules that enforce this.
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Frontend organization
|
|
2
|
+
|
|
3
|
+
One rule generates the whole structure: **every piece of code is co-located at the lowest level it is shared.** If a util, hook, mock, or style is used by exactly one component, it lives in that component's folder. If two sibling components share it, it moves up to their common parent's folder. If two features share it, it moves to `apps/ui/src/shared/`. Code rises as it gains consumers; it never sits higher than it needs to.
|
|
4
|
+
|
|
5
|
+
The component is the unit of organization, not the file.
|
|
6
|
+
|
|
7
|
+
This applies to `apps/ui/`. The other apps (`apps/api/`, `apps/agent/`) follow [architecture.md](architecture.md)'s server-side layout.
|
|
8
|
+
|
|
9
|
+
## Per-component folder
|
|
10
|
+
|
|
11
|
+
A component folder is named after the component (PascalCase). It contains whichever of these files the component actually needs — none are required except the `.tsx` itself:
|
|
12
|
+
|
|
13
|
+
- `Component.tsx` — the component
|
|
14
|
+
- `Component.test.tsx` — its tests
|
|
15
|
+
- `Component.mock.ts` — test/storybook mocks for this component
|
|
16
|
+
- `Component.module.scss` — component-local styles
|
|
17
|
+
- `Component.util.ts` / `Component.util.test.ts` — component-local pure helpers
|
|
18
|
+
- `Component.hook.tsx` / `Component.hook.test.tsx` — component-local hooks
|
|
19
|
+
|
|
20
|
+
The naming convention (`Component.<role>.<ext>`) keeps the folder grepable and the relationship between files explicit. A util that has graduated to multiple consumers gets renamed and lifted; until then, it stays scoped to the component that owns it.
|
|
21
|
+
|
|
22
|
+
No `index.ts` barrel by default. Add one only when it meaningfully shortens imports across feature boundaries — barrels in every folder create maintenance load and obscure the call graph.
|
|
23
|
+
|
|
24
|
+
## Nested components
|
|
25
|
+
|
|
26
|
+
A component that owns sub-components puts each sub-component in its own folder _inside_ the parent's folder, with the same co-location rules. Nesting is recursive: a sub-component with its own private util keeps that util next to it, not at the parent level.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
apps/ui/src/strategy/
|
|
30
|
+
├── StrategyList/
|
|
31
|
+
│ ├── StrategyList.tsx
|
|
32
|
+
│ ├── StrategyList.test.tsx
|
|
33
|
+
│ ├── StrategyList.hook.tsx // private to StrategyList
|
|
34
|
+
│ ├── StrategyRow/ // sub-component, owned by StrategyList
|
|
35
|
+
│ │ ├── StrategyRow.tsx
|
|
36
|
+
│ │ ├── StrategyRow.test.tsx
|
|
37
|
+
│ │ └── StrategyRow.module.scss // private to StrategyRow
|
|
38
|
+
│ └── RenameForm/ // sub-component, owned by StrategyList
|
|
39
|
+
│ ├── RenameForm.tsx
|
|
40
|
+
│ ├── RenameForm.util.ts // private to RenameForm
|
|
41
|
+
│ └── RenameForm.util.test.ts
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
If `StrategyRow` later gets reused outside `StrategyList`, it graduates up to `apps/ui/src/strategy/` (still feature-local) or to `apps/ui/src/shared/components/StrategyRow/` (if used across features). The promotion is driven by the second consumer, never predicted.
|
|
45
|
+
|
|
46
|
+
## Feature directories
|
|
47
|
+
|
|
48
|
+
When the UI grows to multiple distinct concerns — pages, routes, or major feature areas — each gets a top-level folder under `apps/ui/src/<feature>/`. The feature folder contains:
|
|
49
|
+
|
|
50
|
+
- The feature's top-level component(s)
|
|
51
|
+
- Per-component folders (same rules as above)
|
|
52
|
+
- Feature-private hooks/utils/mocks alongside the components that use them
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
apps/ui/src/
|
|
56
|
+
├── strategy/ // feature
|
|
57
|
+
│ ├── StrategyList/...
|
|
58
|
+
│ ├── StrategyCreateForm/...
|
|
59
|
+
│ └── strategy.hook.tsx // hook shared across components in this feature
|
|
60
|
+
├── insights/ // a second feature, hypothetical
|
|
61
|
+
│ └── ...
|
|
62
|
+
└── shared/
|
|
63
|
+
├── components/
|
|
64
|
+
│ └── ErrorBoundary/... // used by both strategy and insights
|
|
65
|
+
├── hooks/
|
|
66
|
+
│ └── useAuth.ts
|
|
67
|
+
└── utils/
|
|
68
|
+
└── format-date.ts
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
A second feature consuming the same code is the trigger to lift it — to `shared/` if it crosses features, to the feature root if it crosses components within a feature.
|
|
72
|
+
|
|
73
|
+
## Shared code
|
|
74
|
+
|
|
75
|
+
Cross-feature shared code lives under `apps/ui/src/shared/`:
|
|
76
|
+
|
|
77
|
+
- `apps/ui/src/shared/components/<ComponentName>/...` — components used by ≥ 2 features
|
|
78
|
+
- `apps/ui/src/shared/hooks/...` — hooks used by ≥ 2 features
|
|
79
|
+
- `apps/ui/src/shared/utils/...` — utils used by ≥ 2 features
|
|
80
|
+
|
|
81
|
+
The `shared/` prefix makes scope explicit at the import path. `import { ErrorBoundary } from '@/shared/components/ErrorBoundary'` reads as "this is intentionally cross-feature." Imports that pierce a feature boundary _without_ going through `shared/` (e.g., `import from '@/strategy/StrategyList'` from inside `insights/`) are a structural smell — either lift the import target to `shared/`, or duplicate (rare) and revisit when a third consumer shows up.
|
|
82
|
+
|
|
83
|
+
No generic top-level `apps/ui/src/components/`, `apps/ui/src/hooks/`, or `apps/ui/src/utils/`. Those names hide the sharing scope; `shared/` makes it explicit.
|
|
84
|
+
|
|
85
|
+
## Tests are co-located
|
|
86
|
+
|
|
87
|
+
Test files sit next to the code they test. No parallel `__tests__/` directory tree. Vitest discovery (see [testing.md](testing.md)) follows the standard `*.test.ts(x)` glob, which works regardless of folder depth.
|
|
88
|
+
|
|
89
|
+
E2E tests stay in `apps/ui/e2e/` — those are not unit tests; they exercise the wired-up app from the outside and are organized by user flow, not by component.
|
|
90
|
+
|
|
91
|
+
## Why these rules
|
|
92
|
+
|
|
93
|
+
- **Co-location at lowest-shared-level minimizes the radius of any change.** Editing a component's private util touches one folder. Lifting it later is mechanical: copy folder up, update imports. Lifting prematurely costs nothing on day one but creates re-coupling pressure (everyone now reaches for the "shared" thing because it's there, not because they need it).
|
|
94
|
+
- **The component-as-folder unit makes deletes safe.** Deleting a feature deletes a folder — all its private utils, hooks, mocks, and tests go with it. A flat layout strands the private helpers in `utils/`, where they outlive their consumers and accumulate.
|
|
95
|
+
- **The `shared/` prefix is a documentation artifact.** Imports declare scope. A reviewer scanning a diff sees `from '@/strategy/...'` (feature-internal) vs `from '@/shared/...'` (cross-cutting) and can judge coupling without reading the file.
|
|
96
|
+
- **It scales with the project.** A 5-component SPA fits one feature folder. A 50-component app with 6 features fits the same rules unchanged — the only difference is more feature folders and a fuller `shared/`.
|
|
97
|
+
|
|
98
|
+
## Existing code: forward-looking
|
|
99
|
+
|
|
100
|
+
The current `apps/ui/src/` does not follow this layout:
|
|
101
|
+
|
|
102
|
+
- 7 components flat in [apps/ui/src/components/](../../apps/ui/src/components/), only `ErrorTagMessage` has a co-located test.
|
|
103
|
+
- 6 hooks plus utilities flat in [apps/ui/src/hooks/](../../apps/ui/src/hooks/), each with a co-located test (already correct for the co-location rule, but lives under the wrong top-level name).
|
|
104
|
+
- No feature folders. No `shared/`.
|
|
105
|
+
|
|
106
|
+
The SPA has one concern (strategy CRUD) and no natural feature boundaries yet, so a retrofit is deferred until the second feature lands. The forcing function is the second consumer.
|
|
107
|
+
|
|
108
|
+
**New components MUST follow this standard.** The next component added is the test case: create a feature folder for it if one doesn't exist, put the component in its own folder inside, co-locate everything it owns.
|
|
109
|
+
|
|
110
|
+
When retrofit eventually happens:
|
|
111
|
+
|
|
112
|
+
- `apps/ui/src/components/` and `apps/ui/src/hooks/` get moved into either a `strategy/` feature folder (if everything is in fact strategy-specific) or `apps/ui/src/shared/components/` and `apps/ui/src/shared/hooks/` (if they're genuinely cross-feature).
|
|
113
|
+
- Per-component folders get created. Test files that already exist stay co-located; `.util.ts` / `.hook.tsx` files are extracted as opportunities arise.
|
|
114
|
+
- This is one PR's worth of mechanical work and an opportunity to delete anything that's been dead since the original flat layout — not a separate principle to enforce.
|
|
115
|
+
|
|
116
|
+
## Relation to other principles
|
|
117
|
+
|
|
118
|
+
- [architecture.md](architecture.md) — onion rings. `apps/ui/` is the Presentation ring. This file describes how it's structured _internally_.
|
|
119
|
+
- [testing.md](testing.md) — Vitest discovery, co-located tests.
|
|
120
|
+
- [clean-code.md](clean-code.md) — small focused functions, DRY/YAGNI/KISS. The co-location rule is a YAGNI in spatial form: don't hoist to `shared/` what only one component uses.
|