Move the two wizard lookups behind application-layer facades so ui/ no longer injects infrastructure adapters directly: - RegistratieLookupStore (BRP address + DUO diplomas): owns the resources, runs the trust-boundary parse, exposes adresStatus/prefillAdres/duoLookup. - IntakePolicyStore (scholing threshold): owns the policy resource, exposes the derived threshold. Add the lint rule ui/ + layout/ ↛ **/infrastructure/** (@typescript-eslint variant so it composes with the base direction rules; stories/specs exempted as test scaffolding). Add the documented showcase sanction (may read every context). Fix the docs' inventory: 6 contexts / 5 layers, +brief, +contracts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
10 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 — in-memory seeded, no DB) and consumed
through an NSwag-generated typed client. The FE renders the backend's decisions.
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/map3/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. 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, titled Layer/Name, a11y addon on),
not heavy component tests.
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. - 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/).
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.