Files
atomic-design-poc/CLAUDE.md
Edwin van den Houdt 64385999eb Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill,
DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't-
validate value objects, infrastructure adapters, plus CLAUDE.md and the
architecture/ADR docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:23:52 +02:00

6.8 KiB

CLAUDE.md

Agent guide for this repo. The why lives in docs/ARCHITECTURE.md and docs/architecture/0001-bff-lite-decision-dtos.md; 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. No real backend/auth — static mock JSON + fake timers.

Commands

npm start          # ng serve → http://localhost:4200
npm test           # vitest
npm run build      # ng build (must stay green)
npm run storybook  # component library by atomic layer

.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, showcase (teaching page, not a feature).

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. Cross-context only herregistratie → registratie → shared, auth → shared. Imports use aliases as direction statements: @shared/* @auth/* @registratie/* @herregistratie/*. 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 Utrecht/RHC CSS classes; we own only a small typed input() API, the design system does the visuals.

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/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.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.
  • 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, 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 for effect()/template-ref injection).
  • Angular-native control flow @if/@for; native httpResource for fetching; withViewTransitions() for page transitions (header/footer have stable view-transition-name, excluded from the fade).
  • Routes: lazy loadComponent, persistent ShellComponent parent, canActivate: [authGuard] on protected routes (app.routes.ts).
  • Theming is one import — src/styles.scss pulls the RHC palette; no hand-written theme.
  • Scenario toggle: ?scenario=slow|loading|empty|error on data pages (scenario.interceptor.ts) to see every async state.
  • Prettier; .editorconfig. tsconfig: noImplicitReturns, noPropertyAccessFromIndexSignature, noFallthroughCasesInSwitch, isolatedModules.

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, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo, runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen.