Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
18 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/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
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/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, 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
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/* @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. 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 libs/shared/src/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 (libs/shared/src/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(ssp'sregistratie/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.
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. Org-templates are the deliberate exception
(operational per-org config in SQLite). 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
or libs/beheer/ui component is titled Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>;
a component in an app 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 (seelibs/shared/src/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(libs/shared/src/kernel/datum.ts). Never a third hand-rolledtoLocaleDateStringcall. - Routes: lazy
loadComponent, persistentShellComponentparent (libs/shared),canActivate: [authGuard]on protected routes (each app's ownapp.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'sindex.html;libs/shared/styles.scss(one copy, both apps'angular.jsonpoint 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|erroron 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-writtenfetch/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⚙ statedev panel). Roles, how to switch, and what each unlocks:docs/reference/roles-and-access.md.adminunlocks 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 onany;npm run dep:check(.dependency-cruiser.base.js+ one.dependency-cruiser.<app>.jsper app, WP-67) fails on illegal imports —domain/importing Angular, a context importing "upward" (theherregistratie → registratie → shared,auth → shareddirection), an app importing the other app's source, orlibs/shareddepending onlibs/beheer. CI (.github/workflows/ci.yml) runs lint +dep:check+check:tokens+ test (both apps + both libraries) + build (both apps), backenddotnet 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.