Files
atomic-design-poc/CLAUDE.md
Edwin van den Houdt 556f2f47bf feat(fp): WP-22 — durable persistence (SQLite/EF Core)
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>
2026-07-05 10:19:23 +02:00

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 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.tscreateStore(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.