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>
12 KiB
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
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 withmap/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 bydispatch(msg)→ purereduce(model, msg). Models are tagged unions (seeherregistratie.machine.ts,intake.machine.ts). Templates send messages, never mutate.createStoreis the one wiring idiom — a page never hand-rollssignal(model)+ a localdispatch(). Naming: a top-level machine's State/Msg types are context-prefixed (ChangeRequestState,ChangeRequestMsg), never bareState/Msg; a top-level machine exportsinitial+reduce. A composable sub-machine embedded inside a parent model keeps prefixed value exports instead (initialUpload/reduceUpload, seeupload.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 returningResult(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 foreffect()/template-ref injection). - Angular-native control flow
@if/@for; fetch viaresource({ loader })over the generatedApiClientinside aninfrastructure/*.adapter.ts(one place HTTP lives), with aparse*boundary;withViewTransitions()for page transitions (header/footer have stableview-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 isnl; a second locale is a translation file, not a code change (the seam). Shared/English components must not hardcode Dutch — expose copy asinput()s with localizable defaults; the domain caller supplies the text (seeshared/ui/async). Format-validation messages indomain/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 + asubmit-*command returningResult— the same shape as the wizards, whether it's one step or many. Don't hand-roll mutable fields + ad-hoc error signals. - Dates:
DatePipein templates,formatDatumNlin pure TS. A template formats a date with Angular'sDatePipe(| date: 'longDate'); pure TS that can't reach a pipe (a domain function, a$localizestring) uses the one hand-writtenformatDatumNl(shared/kernel/datum.ts). Never a third hand-rolledtoLocaleDateStringcall. - Routes: lazy
loadComponent, persistentShellComponentparent,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>inindex.html;src/styles.scssholds 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|erroron 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 onanyand on illegal imports —domain/importing Angular, or a context importing "upward" (theherregistratie → registratie → shared,auth → shareddirection). CI (.github/workflows/ci.yml) runs lint +check:tokens+ test + build, backenddotnet 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.