Applications, documents (+ audit log) and the brief move off static in-memory Dictionaries onto a real SQLite file via EF Core, so demo data survives a process restart or `docker compose restart api` for the first time. The three stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public signatures and static-class shape — no DI, no async ripple into Program.cs's minimal-API handlers — each method just opens a short-lived AppDbContext via Db.Create() under the same lock it already had. Opaque nested shapes (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as JSON text columns rather than redesigned into relational tables, matching the existing "don't interpret it" posture. Found two things the WP's own text got wrong, corrected in docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which stay in-memory) so there's no seed step; and no new docker-compose volume is needed since the existing bind mount already covers the SQLite file — verified against this environment's real podman-backed compose stack, not just by reading the file. Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own transitive default bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption advisory); found and fixed a real xUnit test race where concurrent test-class hosts stomped a shared static connection-string field, fixed by disabling cross-class test parallelization rather than adding DI the stores don't otherwise need. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
190 lines
12 KiB
Markdown
190 lines
12 KiB
Markdown
# CLAUDE.md
|
|
|
|
Agent guide for this repo. The _why_ lives in `docs/ARCHITECTURE.md`,
|
|
`docs/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide
|
|
`docs/fp-tea-atomic-design.md` (FP + The Elm Architecture + atomic design); this
|
|
file is the _rules_. When a decision below and those docs disagree, the docs win —
|
|
update this file.
|
|
|
|
POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
|
|
view their registration, apply for re-registration). Angular 22, standalone,
|
|
signals. Auth is faked; **data and business rules are served by a minimal ASP.NET
|
|
Core backend** (`backend/`, see its README) and consumed through an NSwag-generated
|
|
typed client. The FE renders the backend's decisions. Reference data mimicking
|
|
BRP/DUO (`Data/SeedData.cs`) is in-memory; applications, documents and the brief
|
|
persist to a SQLite file via EF Core (WP-22) — `docs/backlog/WP-22-durable-persistence.md`.
|
|
|
|
## Commands
|
|
|
|
```bash
|
|
npm start # ng serve (proxies /api → backend) → http://localhost:4200
|
|
npm test # vitest
|
|
npm run lint # eslint — enforces `any`-free code + import/layer boundaries
|
|
npm run build # ng build (must stay green)
|
|
npm run storybook # component library by atomic layer
|
|
npm run gen:api # regenerate the typed client from the backend OpenAPI doc
|
|
docker compose up # run FE + backend together (Swagger at :5000/swagger)
|
|
cd backend && dotnet test # backend rule + endpoint tests
|
|
```
|
|
|
|
`.npmrc` sets `legacy-peer-deps=true` (Storybook's peer range lags Angular 22).
|
|
Do not run `npm audit fix --force` — it downgrades Angular 22→21. Dev-only
|
|
advisories are pinned via `package.json` `overrides`; the shipped bundle audits clean.
|
|
|
|
## The decisions (non-negotiable working agreements)
|
|
|
|
### 1. DDD: contexts then layers, dependencies point inward
|
|
|
|
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
|
|
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
|
|
page, not a feature; **sanctioned** to read every context — nothing imports it).
|
|
|
|
| Layer | Job | Angular allowed? |
|
|
| ----------------- | ----------------------------------------- | -------------------------------- |
|
|
| `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` |
|
|
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
|
|
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
|
|
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
|
|
| `ui/` | how it looks (components, pages) | yes |
|
|
|
|
**Dependencies only point inward**: `ui → application → domain`; everyone may use
|
|
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
|
|
(reach data through an application store/command) — lint-enforced. Cross-context only
|
|
`herregistratie → registratie → shared`, `auth → shared`, `brief → shared`. Imports use
|
|
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
|
|
@brief/*`. `domain/` imports nothing from Angular.
|
|
|
|
### 2. Atomic design: folder = layer
|
|
|
|
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
|
|
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page
|
|
should be **composition of existing blocks** — adding building blocks is the
|
|
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
|
|
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
|
|
the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
|
|
small hand-rolled surface built from the token bridge; see ADR-0003.)
|
|
|
|
### 3. State: make illegal states unrepresentable
|
|
|
|
Default reflex — **if you're about to add a second/third boolean to track state,
|
|
model a discriminated union instead.** Three tools, all in `shared/application`:
|
|
|
|
- **`RemoteData<E,T>`** (`remote-data.ts`) — `Loading | Empty | Failure{error} | Success{value}`.
|
|
Combine sources with `map`/`map2`/`andThen` (Failure > Loading > Success).
|
|
Render it via the `<app-async>` molecule (`shared/ui/async`) — one of four
|
|
templates, mutually exclusive by construction. Default loading spinner/skeleton
|
|
is delay-gated (~250ms) so fast connections don't flash.
|
|
- **Elm-style store** (`store.ts` → `createStore(initial, reduce)`) — all state in
|
|
one Model; change only by `dispatch(msg)` → **pure** `reduce(model, msg)`. Models
|
|
are tagged unions (see `herregistratie.machine.ts`, `intake.machine.ts`). Templates
|
|
send messages, never mutate. **`createStore` is the one wiring idiom** — a page
|
|
never hand-rolls `signal(model)` + a local `dispatch()`. Naming: a top-level
|
|
machine's State/Msg types are context-prefixed (`ChangeRequestState`,
|
|
`ChangeRequestMsg`), never bare `State`/`Msg`; a top-level machine exports
|
|
`initial` + `reduce`. A **composable sub-machine** embedded inside a parent
|
|
model keeps prefixed *value* exports instead (`initialUpload`/`reduceUpload`,
|
|
see `upload.machine.ts`) — prefixing there avoids alias noise at the
|
|
composition site.
|
|
- **`Result<E,T>` + value objects** ("parse, don't validate") — raw input becomes a
|
|
branded type only via a parser returning `Result` (`registratie/domain/value-objects/`:
|
|
`Postcode`, `Uren`, `BigNummer`). Once you hold the type, never re-check it.
|
|
|
|
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are
|
|
`visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
|
|
|
|
**Side effects stay out of the reducer.** A _command_ (`application/submit-*.ts`)
|
|
does the HTTP, then dispatches a message describing the outcome. Reducer = "what the
|
|
new state is"; command = "go do it, then say what happened."
|
|
|
|
**Shared cross-page state = one root singleton.** Stores are `providedIn: 'root'`
|
|
(`BigProfileStore`, `SessionStore`). That single instance is the shared state — no
|
|
NgRx, no extra lib. Optimistic update pattern: `begin*` (flip pending) →
|
|
`confirm*` (clear + `resource.reload()`) / `rollback*` (undo).
|
|
|
|
### 4. BFF-lite + decision DTOs (ADR-0001)
|
|
|
|
`infrastructure/` is the **only** layer that touches the network — the
|
|
anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a
|
|
decision-enriched DTO; **the FE renders decisions, it does not recompute business
|
|
rules.** Per rule, pick: _decision flag_ (server computes the boolean — e.g.
|
|
herregistratie eligibility) or _config value_ (server sends threshold, FE applies
|
|
for instant feedback, server re-validates as authority — e.g. scholing threshold).
|
|
FE keeps only **format** validation, never as authority.
|
|
|
|
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
|
|
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
|
|
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
|
|
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
|
|
but the FE doesn't call them.
|
|
|
|
### 5. Testing
|
|
|
|
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
|
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
|
|
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
|
|
Storybook stories (`*.stories.ts` co-located, a11y addon on), not heavy component tests.
|
|
**Story titles mirror the sidebar's Design System/Domein split** (see
|
|
`src/docs/layers.mdx`): a `shared/ui`/`shared/layout` component is titled
|
|
`Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`; a component in a
|
|
context's `ui/` is titled `Domein/<Context>/<Name>` — full stop, regardless of which
|
|
atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
|
|
|
|
## Conventions
|
|
|
|
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
|
|
constructor DI (constructor only for `effect()`/template-ref injection).
|
|
- Angular-native control flow `@if/@for`; fetch via `resource({ loader })` over the
|
|
generated `ApiClient` inside an `infrastructure/*.adapter.ts` (one place HTTP lives),
|
|
with a `parse*` boundary; `withViewTransitions()` for page transitions (header/footer
|
|
have stable `view-transition-name`, excluded from the fade).
|
|
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
|
|
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
|
|
`*.machine.ts`). Pick the language by which side of the seam the code is on.
|
|
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
|
|
first-party `$localize` (no third-party i18n lib), with a stable custom id
|
|
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
|
|
translation file, not a code change (the seam). Shared/English components must **not**
|
|
hardcode Dutch — expose copy as `input()`s with localizable defaults; the domain caller
|
|
supplies the text (see `shared/ui/async`). Format-validation messages in
|
|
`domain/value-objects/` stay co-located but are still `$localize`-wrapped.
|
|
- **Forms = one idiom.** Any form with validation or submission uses a `*.machine.ts`
|
|
(Model/Msg/reduce) + value objects + a `submit-*` command returning `Result` — the
|
|
same shape as the wizards, whether it's one step or many. Don't hand-roll mutable
|
|
fields + ad-hoc error signals.
|
|
- **Dates: `DatePipe` in templates, `formatDatumNl` in pure TS.** A template formats a
|
|
date with Angular's `DatePipe` (`| date: 'longDate'`); pure TS that can't reach a pipe
|
|
(a domain function, a `$localize` string) uses the one hand-written
|
|
`formatDatumNl` (`shared/kernel/datum.ts`). Never a third hand-rolled
|
|
`toLocaleDateString` call.
|
|
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
|
|
[authGuard]` on protected routes (`app.routes.ts`).
|
|
- Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under
|
|
`public/cibg-huisstijl/` and loaded via a `<link>` in `index.html`; `src/styles.scss` holds a
|
|
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
|
|
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
|
|
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
|
|
on data pages (`scenario.interceptor.ts`) to see every async state.
|
|
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
|
|
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
|
|
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
|
|
on `any` and on illegal imports — `domain/` importing Angular, or a context importing
|
|
"upward" (the `herregistratie → registratie → shared`, `auth → shared` direction).
|
|
CI (`.github/workflows/ci.yml`) runs lint + `check:tokens` + test + build, backend
|
|
`dotnet test`, and an API-client drift check.
|
|
|
|
## Adding a feature (recipe)
|
|
|
|
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
|
|
`httpResource` or command returning `Result`) → application (store if shared state;
|
|
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
|
|
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
|
|
|
|
The recipes are also invocable skills in `.claude/skills/`: `new-feature`,
|
|
`new-context`, `value-object`, `form-machine`, `bff-endpoint`, `mutation-command`,
|
|
`ui-component`, `new-ssp` (bootstrap a new portal from this template).
|
|
|
|
## Out of scope (POC, don't build unprompted)
|
|
|
|
Real auth/DigiD, NgRx, licensed RO/Rijks fonts + logo (system-font stack; text wordmark),
|
|
runtime DTO validation on every endpoint, multi-tab session sync.
|