Files
atomic-design-poc/CLAUDE.md
T
ehoandClaude Opus 5 6330773fd5
CI / changes (push) Successful in 16s
CI / lint (push) Successful in 3m0s
CI / frontend (push) Failing after 3m36s
CI / backend (push) Successful in 2m38s
CI / e2e (push) Failing after 4m14s
CI / storybook-a11y (push) Failing after 7m50s
CI / semgrep (push) Successful in 1m18s
CI / api-client-drift (push) Successful in 1m55s
chore: add a Taskfile facade over the existing commands
`task` with no arguments lists every runnable command. The Taskfile calls the
npm scripts, dotnet and docker compose. It does not duplicate their logic.
CI does not need `task`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 08:56:39 +02:00

22 KiB

CLAUDE.md

Agent guide for this repo. The why lives in docs/reference/architecture/ARCHITECTURE.md, docs/reference/architecture/0001-bff-lite-decision-dtos.md, and the learning guide docs/reference/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/project/archive/backlog/WP-22-durable-persistence.md.

Monorepo (WP-67): two Angular projects share one backend + one shared library — apps/ssp (Zorgverlener self-service, this doc's main subject) and apps/behandelportal (Behandelaar backoffice, ADR-0002). Both import libs/shared (design system + kernel + generated API client) and libs/beheer (the admin/stamdata context, used identically by both). backend/ is unowned by either — a genuinely shared dependency.

Commands

npm start                     # ng serve ssp (proxies /api → backend) → http://localhost:4200
npm run start:behandelportal  # ng serve behandelportal → http://localhost:4201
npm test                      # vitest — both apps + both shared libraries (ssp, behandelportal, shared, beheer)
npm run lint                  # eslint — enforces `any`-free code + import/layer boundaries
npm run build                 # ng build ssp && ng build behandelportal (must stay green)
npm run storybook              # ssp's component library by atomic layer
npm run storybook:behandelportal  # behandelportal's own instance (see "Monorepo" note below)
npm run gen:api                # regenerate the ONE typed client (libs/shared) from the backend OpenAPI doc
npm run ci                    # run the CI gate locally BEFORE pushing (mirrors ci.yml); `npm run ci --full` adds storybook-a11y
docker compose up             # run both FE apps + backend together (Swagger at :5000/swagger)
cd backend && dotnet test     # backend rule + endpoint tests
task                          # list every task (a thin facade over the commands above)

Two Storybook instances, not one: apps/ssp and apps/behandelportal each have their own auth context at the same @auth/* alias pointing at different physical directories — a single merged tsconfig can't resolve both at once, so .storybook-ssp/ and .storybook-behandelportal/ are separate config dirs (npm run storybook[:behandelportal] / build-storybook[:behandelportal]), each globbing its own app's stories + both shared libraries'.

Run npm run ci before every push (scripts/ci-local.sh) — it runs the same jobs Gitea CI does (lint, format:check, check:tokens, test, ng build --localize, audit, backend format+test, api-client drift), so a red build is caught locally. Two ways to make it automatic: npm run ci by hand, or enable the opt-in hook with git config core.hooksPath scripts/githooks (runs it on git push; bypass once with --no-verify). The e2e + storybook-a11y jobs need a browser/servers — run --full for storybook-a11y; e2e separately (the script prints how).

Second-locale gate: messages.en.xlf is a hand-maintained translation of every $localize id; ng build --localize fails (via i18nMissingTranslation: error) if any id lacks an English <target>. Add one whenever you add a $localize string — npm run ci catches a miss before CI does.

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

Model routing for agent delegation

Three custom agents in .claude/agents/ pin the model to the step, not the whole session — so this doesn't depend on a human remembering to run /model at the right moment:

  • planner (Opus) — design/approach work: a WP's Decisions block, an ambiguous bug's root cause, sequencing a multi-file change. No Edit/Write access; hands back a plan.
  • developer (Sonnet) — implementation once the approach is settled: routine code against a pre-made plan, ending green (npm run ci).
  • task-runner (Haiku) — simple, read-only, mechanical checks: running a test suite, git status/grep, verifying a file exists. No Edit/Write access.

Delegate to the matching agent only when the current session isn't already on that model — don't add indirection for its own sake. docs/project/archive/backlog/README.md's session protocol is the worked example of this in practice.

The decisions (non-negotiable working agreements)

1. DDD: contexts then layers, dependencies point inward

apps/<app>/src/app/<context>/<layer>/ for an app-local context; libs/<lib>/src/<layer>/ for a cross-app library (WP-67). Two apps today: apps/ssp (Zorgverlener self-service — contexts auth, overzicht (the portal home; composes registratie's dashboard sections plus its own cross-context nav sections), registratie, herregistratie, brief (letter-composition teaching slice), showcase (teaching page, not a feature; sanctioned to read every context in its own app — nothing imports it)) and apps/behandelportal (Behandelaar backoffice, ADR-0002 — contexts auth, behandeling). Two cross-app libraries: libs/shared (the design system + kernel + generated API client — no business logic) and libs/beheer (the admin/stamdata context, identical for both apps today — WP-67 folded a silently-diverging duplicate copy back into one). auth is deliberately not shared even though today it's near-identical in both apps — ADR-0002 models Zorgverlener/Medewerker as different Principal variants with different login flows; the two copies are expected to diverge.

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; every context in either app may use libs/shared and libs/beheer; never the reverse (libs/shared may not depend on libs/beheer either — it stays the base). ui/layout never import infrastructure directly (reach data through an application store/command) — lint-enforced (per app, since each app is cruised against its own tsconfig — WP-67's .dependency-cruiser.base.js + one thin .dependency-cruiser.<app>.js per app). An app may not import the other app's source directly. Cross-context only overzicht → registratie → libs/shared|beheer, herregistratie → registratie → libs/shared|beheer, auth → libs/shared|beheer, brief → libs/shared|beheer (ssp); behandeling → libs/shared|beheer, auth → libs/shared|beheer (behandelportal). Imports use aliases as direction statements: @shared/* @beheer/* @auth/* @overzicht/* @registratie/* @herregistratie/* @brief/* (ssp) — @shared/* @beheer/* @auth/* @behandeling/* (behandelportal); each app's own tsconfig.json declares its full map (the root tsconfig.json intentionally has no paths — see its comment). domain/ imports nothing from Angular.

2. Atomic design: folder = layer

libs/shared/ui atoms → molecules → organisms; libs/shared/layout templates (shell, page-shell); each app's own context ui/ pages. Each level only uses levels below, and a shared component takes nav/copy as input()s or an injection token (e.g. HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS, DEBUG_PANEL in shell.component.ts) rather than hardcoding one app's content — the two apps' primary nav genuinely differs. 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. skeleton, spinner — the atom is a small hand-rolled surface built from the token bridge and carries a // CIBG-GAP EXTENSION: marker; see ADR-0003. alert is not such a case: it wraps the vendored .feedback feedback-* classes.)

The step-component contract. A wizard step follows the same rule as address-fields.component.ts: values in, events out, no internal state. Three clauses:

  1. Inputs down. A step reads its data only from input()s the container passes it.
  2. One narrow output up. A step emits one specific event, not the container's whole dispatch.
  3. dispatch is never passed down. The container owns the Model and decides what a step's event means; a step never calls dispatch itself.

Corollary: a step gets no story of its own. The wizard's own story already mounts every step, because it seeds the machine.

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 libs/shared/src/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 (libs/shared/src/ui/molecules/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 (ssp's 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.

The generated client (libs/shared/src/infrastructure/api-client.ts, npm run gen:api, drift-checked in CI) is the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written contracts/*.dto.ts is the exception, only where codegen does not reach the endpoint or types it too loosely (the four survivors are all the latter — the generator emits every property as optional and flattens unions); such a file must still import nothing. Either way a hand-written parse*/toDomain in infrastructure/ validates the untrusted shape and maps DTO → domain — a generated type is a compile-time claim about the wire, not a runtime guarantee. Wiring a real .NET backend touches only infrastructure/ + contracts/ (see ARCHITECTURE §6). Server-owned rules live only on the server, with no FE mirror to drift from it — the FE may mirror a server-supplied value (a threshold, a bound) for instant feedback, but never reimplements the algorithm.

Business-tunable reference data ("stamdata") is config-as-code, not a DB. Tables the business controls (profession↔diploma map, thresholds, policy-question text) live as typed C# in backend/.../Stamdata/, validated at build by StamdataValidationTests (a bad edit fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in code, an unknown key fails closed, the value is operational rather than a shared business rule, and writes are admin-capability-gated and audited. Two surfaces pass it today — OrgTemplateStore (per-org letterhead) and FeatureFlagStore (rollout switches), both in SQLite. A third surface must pass the same test, not argue by analogy. UI copy is $localize. See ADR-0004.

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 — each app has its own Storybook instance (.storybook-ssp/, .storybook-behandelportal/, WP-67 — a single merged tsconfig can't resolve both apps' @auth/* at once), each globbing its own app's stories plus both shared libraries'. Story titles mirror the sidebar's Design System/Domein split (see libs/shared/docs/layers.mdx): a libs/shared/ui|layout component is titled Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>; a component in an app context's ui/, or in libs/beheer/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.
  • English prose uses Simplified Technical English (STE). This covers documentation, code comments, commit messages, ADRs, and the backlog notes. One idea per sentence; 20 words or fewer in a procedure, 25 in a description. Active voice, present tense. One word for one meaning — pick a term and repeat it, do not vary it for style. Keep articles ("the test fails"). Three nouns together at most. No idioms and no humour. Six sentences per paragraph at most. Write a procedure as numbered steps, one action per step. STE governs form, not content. Split a long sentence; never drop a caveat, a measurement, or a precise term to make it shorter. STE does not apply to Dutch identifiers, $localize copy, quoted output, or existing documents you are not already editing.
  • 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 libs/shared/src/ui/molecules/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 (libs/shared/src/kernel/datum.ts). Never a third hand-rolled toLocaleDateString call.
  • Routes: lazy loadComponent, persistent ShellComponent parent (libs/shared), canActivate: [authGuard] on protected routes (each app's own app.routes.ts).
  • Theming: CIBG Huisstijl (a customized Bootstrap 5.2 build) is vendored under public/cibg-huisstijl/ and loaded via a <link> in each app's index.html; libs/shared/styles.scss (one copy, both apps' angular.json point at it — WP-67) 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 — sticky per tab (change it via a full navigation, not an in-app link). Hand-written fetch/XHR calls (uploads, /brief/preview, /admin/org-template/*/preview, /brief/reveal-bignummer) bypass the interceptor.
  • Dev role stand-in (dev-only): ?role=drafter|approver|admin (or the ⚙ state dev panel). Roles, how to switch, and what each unlocks: docs/reference/roles-and-access.md. admin unlocks the capability-gated pages: /brief/huisstijl (org-template editor), /beheer/stamdata, /beheer/zaken, /beheer/audit, /beheer/functies.
  • Prettier; .editorconfig. tsconfig: noImplicitReturns, noPropertyAccessFromIndexSignature, noFallthroughCasesInSwitch, isolatedModules.
  • Enforced, not just hoped-for: npm run lint (eslint.config.mjs, scoped to {apps,libs}/**) fails the build on any; the same config's max-lines rule caps every {apps,libs}/**/*.{page,component,section,step}.ts file at 250 lines (skipBlankLines: true, skipComments: true). 250 is reachable, not a style-guide default — the dashboard page lands at 42 lines. npm run dep:check (.dependency-cruiser.base.js + one .dependency-cruiser.<app>.js per app, WP-67) fails on illegal imports — domain/ importing Angular, a context importing "upward" (the herregistratie → registratie → shared, auth → shared direction), an app importing the other app's source, or libs/shared depending on libs/beheer. CI (.github/workflows/ci.yml) runs lint + dep:check + check:tokens + test (both apps + both libraries) + build (both apps), backend dotnet test, and an API-client drift check (one generated client, libs/shared/src/infrastructure/api-client.ts).

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 libs/shared/ui atoms, wrap async in <app-async>, dispatch messages). Worked example: the SSP's 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), document-feature (ship/update docs in the same diff as the code).

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.