docs: add Foundations learning path — paced 3-day onboarding route
CI / e2e (push) Waiting to run
CI / frontend (push) Failing after 1m4s
CI / storybook-a11y (push) Failing after 4m40s
CI / backend (push) Successful in 1m19s
CI / codeql (csharp) (push) Failing after 1m49s
CI / codeql (javascript-typescript) (push) Failing after 1m28s
CI / api-client-drift (push) Failing after 1m22s
CI / e2e (push) Waiting to run
CI / frontend (push) Failing after 1m4s
CI / storybook-a11y (push) Failing after 4m40s
CI / backend (push) Successful in 1m19s
CI / codeql (csharp) (push) Failing after 1m49s
CI / codeql (javascript-typescript) (push) Failing after 1m28s
CI / api-client-drift (push) Failing after 1m22s
New src/docs/learning-path.mdx sequences the existing Foundations docs into self-contained, bite-size lessons (Day 1 orient / Day 2 functional core / Day 3 quality + capstone), each with goal, time, exercise, and self-check. Insert it into the storySort order after Overview, add a pointer from the Overview hub, and fix a stale cibg cross-link in atomic-design.mdx. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -105,7 +105,7 @@ one place to fix a bug.
|
||||
Periodically we audit for near-duplicate blocks. Some collapse into one; a few **look**
|
||||
similar but earn their separation. This table records the "don't merge these" verdicts so
|
||||
the next person doesn't spend an afternoon re-deciding. (Deliberate CIBG-specific deviations
|
||||
live in [CIBG gaps](?path=/docs/foundations-cibg-gaps--docs); the FE⇄DS "same shape, different
|
||||
live in [CIBG gaps](?path=/docs/foundations-cibg-gap-register--docs); the FE⇄DS "same shape, different
|
||||
context" cases in [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs).)
|
||||
|
||||
| Pair | Why kept separate |
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
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.
|
||||
|
||||
**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. 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.
|
||||
@@ -8,6 +8,10 @@ The **why** behind this codebase, as a short curriculum. Each page is a condense
|
||||
cross-linked take on one idea; the long-form source lives in `docs/reference/`
|
||||
(see the repo's `docs/README.md`). Read them in roughly this order.
|
||||
|
||||
> **New here?** Follow the [Learning Path](?path=/docs/foundations-learning-path--docs)
|
||||
> for a paced 3-day route with exercises and self-checks. This page is the map; the
|
||||
> Learning Path is the route through it.
|
||||
|
||||
## Architecture & domain
|
||||
|
||||
- [Domain-driven design](?path=/docs/foundations-domain-driven-design--docs) — bounded
|
||||
|
||||
Reference in New Issue
Block a user