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>
376 lines
19 KiB
Plaintext
376 lines
19 KiB
Plaintext
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 3–4.
|
||
|
||
### 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.
|