Files
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
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>
2026-08-02 21:01:57 +02:00

376 lines
19 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Meta } from '@storybook/addon-docs/blocks';
<Meta title="Foundations/Learning Path" />
# Learning path
A paced, hands-on route through this codebase for a developer who is a **strong
programmer but new to frontend functional programming**. The [Overview](?path=/docs/foundations-overview--docs)
is the _map_ — every idea, cross-linked. This is the _route_: what to read first, what
to **do** to make it stick, and how to know you understood it. Work through it over
roughly three days.
Each lesson has the same shape:
- **Goal** — one sentence: what you'll be able to do.
- **~time** — a rough budget so a day stays a day.
- a few paragraphs that **teach the idea** (self-contained — you can read straight
through), then
- **Do** — a hands-on exercise. Most reuse the repo's invocable skills (`/new-feature`,
`/form-machine`, …), which scaffold real code the house way.
- **Check yourself** — a question; if you can answer it, move on.
- **Go deeper** — the deep-dive page and the long-form source in `docs/reference/`.
The one idea underneath everything: **make illegal states unrepresentable.** Every rule
below is a way to stop the compiler letting you build a state that can't actually happen.
---
## Day 1 — Orient: the shape of the codebase
### 1.1 Why this exists — state that can lie · ~15 min
**Goal:** name the failure mode this whole architecture is designed to prevent.
Most UI bugs are not wrong algorithms — they're **impossible states that the types
allowed anyway**. `isLoading` true _and_ `error` set _and_ `data` present: three
booleans give eight combinations, but only four are real. The extra four are bugs
waiting to be rendered. The reflex this codebase trains: when you reach for a second or
third boolean to track one thing, model a **discriminated union** instead, so the
illegal combinations can't be typed.
The second big idea is structural. The folder layout is not filing — **the folder
structure _is_ the architecture**. Where a file lives declares what it's allowed to
depend on, and that rule is enforced by lint, not hoped for. You'll meet the same
"compose small honest pieces, forbid the illegal combinations" principle at three
scales today and tomorrow: in the domain model, in the component tree, and in state.
**Do:** open `src/app/` and read the top of `CLAUDE.md` ("The decisions"). Just get the
lay of the land — six contexts, five layers.
**Check yourself:** three booleans model how many states, and how many are real for a
"fetch"? Why is that gap the enemy?
**Go deeper:** `docs/reference/fp-tea-atomic-design.md` Part 1.
### 1.2 Domain-driven design: contexts then layers · ~25 min
**Goal:** predict which imports are legal before the linter tells you.
Code is organised first by **bounded context** — a business capability with its own
language: `shared`, `auth`, `registratie`, `herregistratie`, `brief`, `showcase`. Inside
each context are five **layers**, and dependencies only ever point **inward**:
| Layer | Job | Angular? |
| ----------------- | ----------------------------------------- | -------------------------------- |
| `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) | yes |
| `contracts/` | wire DTOs (the FE⇄BE seam) | no |
| `ui/` | how it looks | yes |
`ui → application → domain`, never the reverse; `ui` never touches `infrastructure`
directly. Cross-context is one-directional too: `herregistratie → registratie → shared`,
`auth → shared`, `brief → shared`. A context downstream may lean on one upstream; the
upstream never learns the downstream exists. This keeps the domain pure and testable and
stops the dependency graph rotting into a ball of mud.
**Do:** open `eslint.config.mjs` and find the import-boundary rules. Then pick any file
in `herregistratie/` and trace one import back into `registratie` or `shared`.
**Check yourself:** why may `herregistratie` import from `registratie`, but `registratie`
may **not** import from `herregistratie`? What breaks if you invert it?
**Go deeper:** [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs);
`docs/reference/architecture/ARCHITECTURE.md` §1.
### 1.3 Atomic design: composition is the default · ~20 min
**Goal:** decide, for a new screen, whether to add a building block or just compose.
The design system is a ladder: **Atoms → Molecules → Organisms → Templates**, each level
built only from the level below. Atoms (`button`, `form-field`) are thin typed wrappers
over CIBG Huisstijl CSS classes; molecules compose atoms; organisms compose molecules;
templates lay out organisms; a context's `ui/` page composes templates. A new page should
be **composition of existing blocks** — adding a block is the exception, not the reflex.
Notice this is the same shape as 1.2: small honest pieces, each only allowed to reach
one level down, illegal combinations forbidden by structure. That's not a coincidence —
you'll see why tomorrow.
**Do:** trace a real composition chain in Storybook: **Atoms → Button**, then find where
it's used up through `form-field → document-upload → page-shell`. Watch each level only
reach one level down.
**Check yourself:** you need a new "application summary" screen. What's the first
question you ask before writing a component?
**Go deeper:** [Atomic design](?path=/docs/foundations-atomic-design--docs). Adding a
block (only when composition truly can't do it): the `/ui-component` skill.
---
## Day 2 — The functional core
### 2.1 FP fundamentals · ~25 min
**Goal:** read code as "functional core, imperative shell" and spot which is which.
Four tools do the heavy lifting. **Pure functions:** output depends only on input, no
side effects — trivially testable, no mocks. **Immutability:** you compute new values,
you don't mutate old ones, so nothing changes under you. **Unidirectional flow:** data
moves one way (state → view → message → new state), never a tangle of two-way bindings.
**Sum and product types:** a _product_ is "A and B" (a record); a _sum_ is "A **or** B"
(a discriminated union) — sums are how you make illegal states unrepresentable.
Put together: the **functional core** is pure logic (all of `domain/`, the reducers, the
parsers) that knows nothing about Angular or HTTP; the **imperative shell** (components,
adapters) does the messy I/O and hands data in and out of the core. Bugs hide in the
shell; the core stays provable.
**Do:** open any `domain/` file next to its `.spec.ts` and confirm the spec uses no
Angular `TestBed` — it calls the function directly. That's the core being pure.
**Check yourself:** which of these is a sum type and why — "a form field's value" vs. "a
form's submission state (idle / submitting / failed / done)"?
**Go deeper:** [FP in the UI](?path=/docs/foundations-fp-in-the-ui--docs);
`docs/reference/fp-tea-atomic-design.md` Part 2.
### 2.2 State machines — The Elm Architecture · ~30 min
**Goal:** model a form as `Model → Msg → reduce`, with effects kept out of the reducer.
Every form and wizard here is one state machine: a **Model** (a tagged union — the
current state), a **Msg** union (everything that can happen), and a **pure** `reduce(model,
msg): model`. The template never mutates state; it **dispatches a message**, `reduce`
returns the next model, the view re-renders. All wiring goes through one idiom,
`createStore(initial, reduce)` — you never hand-roll `signal(model)` + a local dispatch.
The rule that keeps `reduce` pure: **side effects live in commands, not 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." And **derive, don't store** anything you can compute — e.g. a wizard's visible
steps are `visibleSteps(answers)`, not a stored field.
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
draft to the backend, an effect that lives _outside_ the reducer. See
`docs/reference/architecture/ARCHITECTURE.md` §2g.
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
length). Read the generated Model / Msg / reduce and its spec.
**Check yourself:** why can't `reduce` make the HTTP call itself? What goes wrong if it
does?
**Go deeper:** [State machines (TEA)](?path=/docs/foundations-state-machines-tea--docs);
`docs/reference/fp-tea-atomic-design.md` Parts 34.
### 2.3 RemoteData & async · ~20 min
**Goal:** replace loading/error/empty booleans with one four-state value.
`RemoteData<E,T>` is a sum type with exactly four cases: `Loading | Empty |
Failure{error} | Success{value}`. That's the four _real_ states from lesson 1.1, and no
others — you literally cannot construct "loading and error." Combine sources with
`map` / `map2` / `andThen` (precedence: Failure > Loading > Empty > Success), and render
it with the `<app-async>` molecule, which picks one of four mutually-exclusive templates
by construction. The default spinner is delay-gated (~250 ms) so fast connections don't
flash.
**Do:** open a data page in the running app with `?scenario=slow`, then `?scenario=empty`,
then `?scenario=error` (the dev-only scenario toggle). Watch `<app-async>` switch
templates without any `*ngIf` soup.
**Check yourself:** a page combines two independent fetches with `map2`. One is still
loading, the other has failed — what does the combined value show, and why that
precedence?
**Go deeper:** [RemoteData & Async](?path=/docs/foundations-remotedata-async--docs);
`docs/reference/architecture/ARCHITECTURE.md` §2.
### 2.4 Parse, don't validate · ~20 min
**Goal:** turn untrusted input into a domain type once, then trust it forever.
Raw input (`unknown`, a string, a wire DTO) becomes a **branded value object** only by
passing through a **parser** that returns `Result<E,T>` — `parsePostcode`, `parseUren`,
`parseBigNummer`. Once you hold a `Postcode`, its shape is guaranteed by the type system;
you **never re-check it**. This happens in two places: value objects (form fields) and
boundary `parse*` adapters in `infrastructure/` (the FE⇄BE seam, where untrusted JSON
becomes domain types). "Validate" scatters `if`-checks everywhere and forgets one;
"parse" concentrates the check at the door and lets the compiler enforce the rest.
**Why "brand"?** TypeScript is _structurally_ typed, so a bare `type Postcode = string`
would accept any string and lose all proof of validation. Intersecting a phantom marker —
`string & { readonly __brand: 'Postcode' }` — makes the type **nominal**: no plain string
satisfies it, so the only way to hold a `Postcode` is to go through the parser that stamps
the brand. The brand is compile-time proof the value was validated (it exists only in the
types, never at runtime). The DDD name for the concept is a _value object_; "brand" is just
the TypeScript trick that makes it enforceable.
**Do:** run `/value-object` for a small field (e.g. a Dutch phone number). Read the parser
and its spec — note it returns `Result`, not a boolean, and note the branded type.
**Check yourself:** you're three functions deep and you hold a `Postcode`. Should you
re-validate its format? Why not?
**Go deeper:** [Parse, don't validate](?path=/docs/foundations-parse-dont-validate--docs);
`docs/reference/architecture/ARCHITECTURE.md` §3.
### Day 2 closer — one principle, two scales
You've now seen it twice: **small honest pieces, each only allowed to reach one level
down, with illegal combinations forbidden by structure.** Atomic design applies it to
_components_ (atoms compose upward); The Elm Architecture applies it to _state_ (pure
`reduce` composes messages into models). They are the same principle at two scales — that
is the thesis of this codebase. Read `docs/reference/fp-tea-atomic-design.md` Part 5; it's
the "aha" that ties Day 1 and Day 2 together.
---
## Day 3 — Quality & shipping
### 3.1 Testing strategy — what to test, by layer · ~20 min
**Goal:** know where a test goes and what kind it is, given any change.
Test grain follows the layer. **Domain and pure logic must have a spec** — reducers,
combinators, `visibleSteps`, parsers, boundary `parse*` adapters — tested **directly, no
TestBed**, because they're pure. **UI is exercised via Storybook stories** (co-located
`*.stories.ts`, a11y addon on), not heavy component tests. Backend rules have their own
`dotnet test`. The GREEN gate before you push: `npm run lint && npm test && npm run build`
(plus `cd backend && dotnet test`).
**Do:** run `/test-strategy` and read where it says each layer's test belongs. Then run
`npm test` and watch the pure specs fly (no browser, no mocks).
**Check yourself:** you add a new parser and a new page. Which gets a `.spec.ts`, and
which gets a Storybook story instead?
**Go deeper:** [Testing strategy](?path=/docs/foundations-testing-strategy--docs);
`CLAUDE.md` §5.
### 3.2 BDD — one behaviour per test · ~15 min
**Goal:** write test names that read as a specification in the domain's language.
`describe` names the subject; each `it` states **one observable behaviour** in
present tense — no `should`, no Given/When/Then ceremony. One behaviour per test means one
_behaviour_, not one `expect`: assertions pinning down the same behaviour stay together
(a `Result`'s `.ok` then its `.value`); assertions about different behaviours split apart
(the ok branch **and** the err branch). If a title needs "and"/"then"/"/" to join two
things, that's the smell — split it. And speak the **ubiquitous language**: a _behandelaar_
drafts, a _beoordelaar_ approves — the same words as the bounded contexts.
**Do:** read `registratie/domain/registratie-wizard.machine.spec.ts` — one transition per
test, each named as a behaviour. (You saw this style get enforced when the specs were
recently split.)
**Check yourself:** `it('submits and then shows the reference')` — what's wrong with this
name?
**Go deeper:** [BDD](?path=/docs/foundations-bdd--docs).
### 3.3 Accessibility — four layered tools · ~15 min
**Goal:** know which a11y bug each tool catches, and what only a human catches.
Four layers, each a different bug class: **axe on every story** (CI-gated, catches
contrast/roles/labels), **template a11y lint** (catches missing alt/labels at author
time), **Storybook play tests** (catches keyboard/focus interaction), and a **manual WCAG
checklist** for what automation can't — tab order across a page, focus traps, 200% zoom,
screen-reader narration. a11y is a build gate here, not a nice-to-have.
**Do:** open any story and check the **Accessibility** tab (axe results). Then skim
`docs/reference/wcag-checklist.md` — note the honest empty "Screen reader" column: some
things only a human pass finds.
**Check yourself:** axe passes on a form. Name one real a11y bug it still can't catch.
**Go deeper:** [Accessibility](?path=/docs/foundations-accessibility--docs).
### 3.4 Internationalization — the locale seam · ~15 min
**Goal:** wrap user-facing copy so a second language is a translation file, not a code
change.
Every user-visible string is wrapped in Angular's first-party `$localize` with a stable
custom id — `` $localize`:@@context.key:Tekst` ``. Source locale is `nl`; English is a
translation file, not edited code — that's the seam. Shared/English components must **not**
hardcode Dutch: they expose copy as `input()`s with localizable defaults, and the Dutch
domain caller supplies the text.
**Do:** grep for `$localize` in a `ui/` component; note the `@@`-prefixed stable ids. Find
one `shared/ui` component that takes copy as an `input()` rather than hardcoding it.
**Check yourself:** why must a shared English atom take its label as an `input()` instead
of writing the Dutch word directly?
**Go deeper:** [Internationalization](?path=/docs/foundations-internationalization--docs).
### 3.5 The design-system track (parallel) · ~15 min
**Goal:** style via semantic tokens and the CIBG Huisstijl, never hand-written colours.
This strand is largely independent of the FP/state spine — learn it whenever. The app
speaks a semantic `--rhc-*` token vocabulary; `src/styles.scss` is a **token bridge** that
maps those onto the vendored **CIBG Huisstijl** (a customized Bootstrap 5.2) `--bs-*`
values. Rule: reach for a **CIBG class first, then a token** — no hand-written hex. Where
CIBG lacks a class (e.g. `alert`), the atom is hand-rolled from tokens and recorded in the
**CIBG gap register** so the divergence stays honest.
**Do:** open [Design tokens](?path=/docs/foundations-design-tokens--docs) and read the
live swatches; then skim the [CIBG gap register](?path=/docs/foundations-cibg-gap-register--docs).
**Check yourself:** you need a warning colour. Where does it come from, and where does it
**not**?
**Go deeper:** `docs/reference/architecture/0003-cibg-huisstijl.md` (ADR-0003).
---
## Capstone — add a feature end-to-end
**Goal:** ship one small vertical slice the house way, and name which layer owns each rule.
Two framing ideas first. **BFF-lite + decision DTOs (ADR-0001):** each screen gets one
screen-shaped endpoint returning a **decision-enriched** DTO — the backend computes the
business rules, and **the FE renders decisions, it does not recompute them.** Per rule you
pick a _decision flag_ (server sends the boolean) or a _config value_ (server sends the
threshold, FE applies it for instant feedback, server re-validates as authority). The FE
keeps only **format** validation, never as authority.
Then the house pipeline, always in this order: **domain** (types + pure rules + spec, no
Angular) → **infrastructure** (adapter with a `parse*` boundary, or a command returning
`Result`) → **application** (a store if state is shared; union + pure `reduce`) → **ui**
last (compose `shared/ui` atoms, wrap async in `<app-async>`, dispatch messages).
**Do:** build a tiny slice — e.g. a one-field "update phone number" action — using the
skills in pipeline order:
1. `/value-object` — the field's parser + branded type (domain).
2. `/bff-endpoint` — a screen-shaped read with a decision DTO + `parse*` boundary.
3. `/form-machine` — the form's Model/Msg/reduce.
4. `/mutation-command` — the write, returning `Result`, keeping the reducer pure.
5. `/ui-component` **only if** no existing block composes — otherwise just compose.
`/new-feature` walks the whole pipeline if you'd rather do it in one guided pass.
**Check yourself:** for your slice, name for each business rule whether it's a _decision
flag_ or a _config value_, and which layer owns it. If a rule lives in two layers, which
one is the **authority**?
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
request lifecycle, read + write, with file links). For how contexts scale to a second
app and actor-based authorization, ADR-0002 (the advanced read).
---
You've done the route. From here the [Overview](?path=/docs/foundations-overview--docs)
map is your reference, the deep-dive pages hold the detail, and the skills scaffold each
new piece the house way.