Add FP + Elm Architecture + atomic design learning guide

A progressive teaching guide (docs/fp-tea-atomic-design.md): FP fundamentals,
The Elm Architecture, and atomic design, taught Elm-then-this-app with the real
store/machine/value-object code, plus four recipes and a glossary. It owns the
teaching arc and cross-references ARCHITECTURE.md/ADR-0001 rather than duplicating
them. Documents reality where the PRD diverged (no state-debug-view feature; per-
wizard stores; reduce vs update naming) and flags the absent debug view as an open
question. Adds pointer links from ARCHITECTURE.md and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:40:38 +02:00
parent 770d454a32
commit e5a3030dca
4 changed files with 2062 additions and 1162 deletions

View File

@@ -1,8 +1,10 @@
# CLAUDE.md # CLAUDE.md
Agent guide for this repo. The *why* lives in `docs/ARCHITECTURE.md` and Agent guide for this repo. The _why_ lives in `docs/ARCHITECTURE.md`,
`docs/architecture/0001-bff-lite-decision-dtos.md`; this file is the *rules*. `docs/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide
When a decision below and those docs disagree, the docs win — update this file. `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, POC of a Dutch BIG-register self-service portal (healthcare professionals log in,
view their registration, apply for re-registration). Angular 22, standalone, view their registration, apply for re-registration). Angular 22, standalone,
@@ -24,11 +26,12 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
## The decisions (non-negotiable working agreements) ## The decisions (non-negotiable working agreements)
### 1. DDD: contexts then layers, dependencies point inward ### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`, `src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `showcase` (teaching page, not a feature). `herregistratie`, `showcase` (teaching page, not a feature).
| Layer | Job | Angular allowed? | | Layer | Job | Angular allowed? |
|---|---|---| | ----------------- | ----------------------------------------- | -------------------------------- |
| `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` | | `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) | | `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) | | `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
@@ -41,6 +44,7 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular. @registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer ### 2. Atomic design: folder = layer
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`, `shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page `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 should be **composition of existing blocks** — adding building blocks is the
@@ -48,6 +52,7 @@ 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. we own only a small typed `input()` API, the design system does the visuals.
### 3. State: make illegal states unrepresentable ### 3. State: make illegal states unrepresentable
Default reflex — **if you're about to add a second/third boolean to track state, 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`: model a discriminated union instead.** Three tools, all in `shared/application`:
@@ -67,7 +72,7 @@ model a discriminated union instead.** Three tools, all in `shared/application`:
**Derive, don't store** what you can compute — e.g. the wizard's visible steps are **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`). `visibleSteps(answers)`, not a stored field (`intake.machine.ts`).
**Side effects stay out of the reducer.** A *command* (`application/submit-*.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 does the HTTP, then dispatches a message describing the outcome. Reducer = "what the
new state is"; command = "go do it, then say what happened." new state is"; command = "go do it, then say what happened."
@@ -77,11 +82,12 @@ NgRx, no extra lib. Optimistic update pattern: `begin*` (flip pending) →
`confirm*` (clear + `resource.reload()`) / `rollback*` (undo). `confirm*` (clear + `resource.reload()`) / `rollback*` (undo).
### 4. BFF-lite + decision DTOs (ADR-0001) ### 4. BFF-lite + decision DTOs (ADR-0001)
`infrastructure/` is the **only** layer that touches the network — the `infrastructure/` is the **only** layer that touches the network — the
anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a
decision-enriched DTO; **the FE renders decisions, it does not recompute business decision-enriched DTO; **the FE renders decisions, it does not recompute business
rules.** Per rule, pick: *decision flag* (server computes the boolean — e.g. rules.** Per rule, pick: _decision flag_ (server computes the boolean — e.g.
herregistratie eligibility) or *config value* (server sends threshold, FE applies herregistratie eligibility) or _config value_ (server sends threshold, FE applies
for instant feedback, server re-validates as authority — e.g. scholing threshold). for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority. FE keeps only **format** validation, never as authority.
@@ -92,6 +98,7 @@ rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-
but the FE doesn't call them. but the FE doesn't call them.
### 5. Testing ### 5. Testing
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters). spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via Test the pure function directly — no Angular TestBed for domain. UI is exercised via
@@ -99,13 +106,14 @@ Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on
not heavy component tests. not heavy component tests.
## Conventions ## Conventions
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over - Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
constructor DI (constructor only for `effect()`/template-ref injection). constructor DI (constructor only for `effect()`/template-ref injection).
- Angular-native control flow `@if/@for`; native `httpResource` for fetching; - Angular-native control flow `@if/@for`; native `httpResource` for fetching;
`withViewTransitions()` for page transitions (header/footer have stable `withViewTransitions()` for page transitions (header/footer have stable
`view-transition-name`, excluded from the fade). `view-transition-name`, excluded from the fade).
- Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate: - Routes: lazy `loadComponent`, persistent `ShellComponent` parent, `canActivate:
[authGuard]` on protected routes (`app.routes.ts`). [authGuard]` on protected routes (`app.routes.ts`).
- Theming is one import — `src/styles.scss` pulls the RHC palette; no hand-written theme. - 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 toggle: `?scenario=slow|loading|empty|error` on data pages
(`scenario.interceptor.ts`) to see every async state. (`scenario.interceptor.ts`) to see every async state.
@@ -113,11 +121,13 @@ not heavy component tests.
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`. `noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
## Adding a feature (recipe) ## Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter: Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
`httpResource` or command returning `Result`) → application (store if shared state; `httpResource` or command returning `Result`) → application (store if shared state;
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`, union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`). dispatch messages). Worked example: the intake wizard (`herregistratie/`).
## Out of scope (POC, don't build unprompted) ## Out of scope (POC, don't build unprompted)
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo, Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo,
runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen. runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen.

View File

@@ -1,7 +1,7 @@
# Architecture guide # Architecture guide
A walkthrough of how this app is organised and, especially, **how state is A walkthrough of how this app is organised and, especially, **how state is
managed** — written for a developer who has *not* done functional programming managed** — written for a developer who has _not_ done functional programming
before. No prior FP knowledge assumed. Where we use an FP idea, we explain it in before. No prior FP knowledge assumed. Where we use an FP idea, we explain it in
plain language first. plain language first.
@@ -9,6 +9,11 @@ This is a demo of a Dutch BIG-register self-service portal (a healthcare
professional logs in, sees their registration, and can apply for professional logs in, sees their registration, and can apply for
re-registration — "herregistratie"). re-registration — "herregistratie").
> New to functional programming or The Elm Architecture? Start with the progressive
> learning guide [`fp-tea-atomic-design.md`](./fp-tea-atomic-design.md), which teaches
> the concepts (with Elm ↔ this-app examples) and the recipes; this document is the
> reference deep-dive it points back to.
--- ---
## 1. The big picture: three "contexts", four "layers" ## 1. The big picture: three "contexts", four "layers"
@@ -52,13 +57,13 @@ Inside a context you'll see the same four folders. They answer four different
questions: questions:
| Layer | Answers… | May import Angular? | Example here | | Layer | Answers… | May import Angular? | Example here |
|------------------|---------------------------------------|---------------------|--------------| | ----------------- | ------------------------------------- | ------------------- | ------------------------------------------- |
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` | | `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` | | `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
| `infrastructure/`| Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` | | `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` | | `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
**The one rule that keeps it sane: dependencies only point *inward*.** UI may use **The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the application, application may use domain, everyone may use `shared`. Never the
other way around. The `domain/` layer imports nothing from Angular, so the other way around. The `domain/` layer imports nothing from Angular, so the
business rules are plain functions you can read and test in isolation. business rules are plain functions you can read and test in isolation.
@@ -66,6 +71,7 @@ business rules are plain functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`. Allowed direction: `herregistratie → registratie → shared`, `auth → shared`.
### Why the `shared/` kernel is split too ### Why the `shared/` kernel is split too
- `shared/kernel/` — tiny generic helpers (no Angular). - `shared/kernel/` — tiny generic helpers (no Angular).
- `shared/application/` — generic state tools (RemoteData, the store). - `shared/application/` — generic state tools (RemoteData, the store).
- `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register. - `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
@@ -86,7 +92,7 @@ better types.** Three tools do the work.
### Why not "just signals"? ### Why not "just signals"?
You *can* track a network call with three signals — `isLoading`, `error`, `data`. The You _can_ track a network call with three signals — `isLoading`, `error`, `data`. The
problem is the **state space**: three booleans is 2³ = **8** combinations, and most are problem is the **state space**: three booleans is 2³ = **8** combinations, and most are
nonsense the compiler still lets you write. A single discriminated union has **exactly nonsense the compiler still lets you write. A single discriminated union has **exactly
the 4 states that are real** — the illegal ones can't be expressed at all. the 4 states that are real** — the illegal ones can't be expressed at all.
@@ -114,7 +120,7 @@ graph LR
class b1,b2,b3,g1,g2,g3,g4 ok; class b4,b5,b6 no; class b1,b2,b3,g1,g2,g3,g4 ok; class b4,b5,b6 no;
``` ```
The same argument applies to forms (a `submitting` boolean that can be true *with* The same argument applies to forms (a `submitting` boolean that can be true _with_
validation errors) and to the branching wizard (don't store "which step is next" — it can validation errors) and to the branching wizard (don't store "which step is next" — it can
drift out of sync with the answers; **derive** it instead, see §5). Signals are still the drift out of sync with the answers; **derive** it instead, see §5). Signals are still the
engine underneath; we just give them types that can't lie. engine underneath; we just give them types that can't lie.
@@ -132,7 +138,7 @@ data = signal<Thing | null>(null);
Three signals = eight combinations, and most are nonsense (loading **and** has Three signals = eight combinations, and most are nonsense (loading **and** has
data **and** has an error?). You end up writing defensive `if`s everywhere. data **and** has an error?). You end up writing defensive `if`s everywhere.
Instead we use **one** value that is *exactly one of* four shapes Instead we use **one** value that is _exactly one of_ four shapes
(`shared/application/remote-data.ts`): (`shared/application/remote-data.ts`):
```ts ```ts
@@ -145,7 +151,7 @@ type RemoteData<E, T> =
This is called a **discriminated union** (a.k.a. "tagged union" or "sum type"): This is called a **discriminated union** (a.k.a. "tagged union" or "sum type"):
a value that is one of several labelled shapes, where the `tag` tells you which. a value that is one of several labelled shapes, where the `tag` tells you which.
Notice the data lives *on* the shape — you literally cannot read `.value` unless Notice the data lives _on_ the shape — you literally cannot read `.value` unless
you're in the `Success` case, so "loaded but no data" can't be written down. you're in the `Success` case, so "loaded but no data" can't be written down.
To use it, you handle every case once. The `<app-async>` component To use it, you handle every case once. The `<app-async>` component
@@ -173,7 +179,7 @@ stateDiagram-v2
is still loading, Success only when both succeeded** — so a page renders one state, never a is still loading, Success only when both succeeded** — so a page renders one state, never a
contradictory mix. contradictory mix.
> **FP term:** a *pure function* is one whose output depends only on its inputs > **FP term:** a _pure function_ is one whose output depends only on its inputs
> and which changes nothing else (no network, no writing to variables outside > and which changes nothing else (no network, no writing to variables outside
> it). Pure functions are easy to test and reason about. We push impure things > it). Pure functions are easy to test and reason about. We push impure things
> (HTTP, timers) to the edges. > (HTTP, timers) to the edges.
@@ -205,12 +211,12 @@ when it's safe. (`map`, `map3`, `andThen` are variations on the same idea.)
This is the "Elm-style" pattern. The idea in one sentence: This is the "Elm-style" pattern. The idea in one sentence:
> **Keep all state in one value (the *Model*). The only way to change it is to > **Keep all state in one value (the _Model_). The only way to change it is to
> send a *message* (*Msg*) to a pure function `update(model, msg)` that returns > send a _message_ (_Msg_) to a pure function `update(model, msg)` that returns
> the next Model.** > the next Model.**
Why bother? Because to understand *every* way the screen can change, you read Why bother? Because to understand _every_ way the screen can change, you read
*one* function. No state is mutated anywhere else. _one_ function. No state is mutated anywhere else.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -226,7 +232,7 @@ sequenceDiagram
Note over Reduce: the ONLY place state changes;<br/>no HTTP, no timers, no mutation Note over Reduce: the ONLY place state changes;<br/>no HTTP, no timers, no mutation
``` ```
Side effects (HTTP) sit *outside* this loop: a command does the I/O, then `dispatch`es a Side effects (HTTP) sit _outside_ this loop: a command does the I/O, then `dispatch`es a
message describing the outcome (§2d). So the reducer stays pure and testable. message describing the outcome (§2d). So the reducer stays pure and testable.
The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest
@@ -240,17 +246,23 @@ type WizardState =
| { tag: 'Failed'; data: Valid; error: string }; | { tag: 'Failed'; data: Valid; error: string };
``` ```
Because `step` and `errors` exist *only* on `Editing`, and the other states Because `step` and `errors` exist _only_ on `Editing`, and the other states
carry already-validated `data`, "submitting with validation errors showing" is carry already-validated `data`, "submitting with validation errors showing" is
not expressible. The messages and the pure reducer: not expressible. The messages and the pure reducer:
```ts ```ts
type WizardMsg = type WizardMsg =
| { tag: 'SetField'; key; value } | { tag: 'Next' } | { tag: 'Back' } | { tag: 'SetField'; key; value }
| { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'Next' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error }; | { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error };
function reduce(state, msg) { /* returns the next state; no side effects */ } function reduce(state, msg) {
/* returns the next state; no side effects */
}
``` ```
The component (`herregistratie-wizard.component.ts`) wires it to a signal with The component (`herregistratie-wizard.component.ts`) wires it to a signal with
@@ -288,10 +300,11 @@ then tell the reducer what happened."**
`BigProfileStore` is marked `providedIn: 'root'`, which means Angular creates `BigProfileStore` is marked `providedIn: 'root'`, which means Angular creates
**one** instance for the whole app. Every page that injects it sees the same **one** instance for the whole app. Every page that injects it sees the same
signals. That single shared instance *is* our cross-page state — no extra signals. That single shared instance _is_ our cross-page state — no extra
library needed. library needed.
When the user submits a herregistratie: When the user submits a herregistratie:
1. **Optimistic:** `beginHerregistratie()` flips a `pendingHerregistratie` 1. **Optimistic:** `beginHerregistratie()` flips a `pendingHerregistratie`
signal **before** the server answers. The dashboard already reads that signal **before** the server answers. The dashboard already reads that
signal, so it instantly shows "in behandeling" (in progress). The UI feels signal, so it instantly shows "in behandeling" (in progress). The UI feels
@@ -316,13 +329,14 @@ Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.
## 3. "Parse, don't validate" — value objects ## 3. "Parse, don't validate" — value objects
A raw `string` could be anything. After you've checked a postcode is valid, the A raw `string` could be anything. After you've checked a postcode is valid, the
*type* should remember that. So we have a `Postcode` type that can only be _type_ should remember that. So we have a `Postcode` type that can only be
created by `parsePostcode`, which returns a `Result` (success-or-error) created by `parsePostcode`, which returns a `Result` (success-or-error)
(`registratie/domain/value-objects/`): (`registratie/domain/value-objects/`):
```ts ```ts
const r = parsePostcode(userInput); const r = parsePostcode(userInput);
if (r.ok) save(r.value); // r.value is a Postcode — guaranteed well-formed if (r.ok)
save(r.value); // r.value is a Postcode — guaranteed well-formed
else showError(r.error); // r.error is the message else showError(r.error); // r.error is the message
``` ```
@@ -358,7 +372,7 @@ discriminated union instead.
--- ---
## 5. Branching by *deriving*, not storing ## 5. Branching by _deriving_, not storing
The intake wizard (`herregistratie/domain/intake.machine.ts`) shows the most important The intake wizard (`herregistratie/domain/intake.machine.ts`) shows the most important
state-management habit: **don't store what you can derive.** Naively you'd track "which state-management habit: **don't store what you can derive.** Naively you'd track "which
@@ -378,7 +392,7 @@ function visibleSteps(a: Answers): StepId[] {
``` ```
The state keeps only the raw `answers` and a numeric `cursor`; the visible step is The state keeps only the raw `answers` and a numeric `cursor`; the visible step is
`visibleSteps(answers)[cursor]`. Change "buiten Nederland gewerkt?" to *ja* and the country `visibleSteps(answers)[cursor]`. Change "buiten Nederland gewerkt?" to _ja_ and the country
question simply exists; change it back and it's gone — the cursor is clamped to the new question simply exists; change it back and it's gone — the cursor is clamped to the new
list. There's no synchronisation code to get wrong, and `visibleSteps` is a one-line unit list. There's no synchronisation code to get wrong, and `visibleSteps` is a one-line unit
test. Answers persist to `localStorage` (an `effect` in the component) so a reload resumes test. Answers persist to `localStorage` (an `effect` in the component) so a reload resumes
@@ -403,7 +417,7 @@ update as you type.
Today the adapters read static JSON (`mock/*.json`). Because `infrastructure/` is the only Today the adapters read static JSON (`mock/*.json`). Because `infrastructure/` is the only
layer that touches the network — the **anti-corruption boundary** — pointing the app at a layer that touches the network — the **anti-corruption boundary** — pointing the app at a
real ASP.NET API touches *only these files*. Domain, application and UI don't change. real ASP.NET API touches _only these files_. Domain, application and UI don't change.
The one concrete change per adapter: a **DTO** type matching the .NET response, a The one concrete change per adapter: a **DTO** type matching the .NET response, a
`toDomain` mapper, and a real URL. `toDomain` mapper, and a real URL.

View File

@@ -0,0 +1,640 @@
# Functional programming, The Elm Architecture & atomic design — a guide
A **progressive learning guide** for developers who are strong programmers but have
done little or no **functional programming (FP)** in the frontend. It teaches three
ideas and shows they are one idea:
1. **FP for the frontend** — pure functions, immutability, types that can't lie.
2. **The Elm Architecture (TEA)** — one state, one direction, one pure update.
3. **Atomic design** — small pure components composed into bigger ones.
The claim of Part 5 is that **TEA and atomic design are the same principle at two
scales**, and this app already lives that way.
**How to read it.** A junior can read top-to-bottom and arrive at "I can add a
feature." A senior can skim Parts 14 and jump to **Part 5** (FP × atomic design),
**Part 6** (why it reduces complexity), and **Part 7** (the recipes). Every term is
defined in plain words on first use and again in the **glossary** (Part 8).
This guide is the _teaching_ layer. For the reference deep-dives it points to
[`ARCHITECTURE.md`](./ARCHITECTURE.md) and
[ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) rather than repeating them.
Every code snippet below is real code from this repo, with its file path.
---
## Part 1 — Why this exists (the complexity problem)
Most UI bugs are not algorithmic. They come from **state that can lie**:
- Two booleans that disagree — `isLoading` is `true` _and_ `data` is set.
- A screen that shows an error _and_ a success at the same time.
- A "Submit" that fires while a field is still invalid.
- A wizard whose "next step" field drifts out of sync with the answers.
- The 3am question: _"who changed this value, and when?"_
The root cause is the same each time: state is **scattered** across many mutable
variables, and it changes from **many places**. The number of states explodes, and
most of them are nonsense the compiler still lets you write.
The promise of this architecture, in one line:
> **One state. One direction. Pure logic. Predictable everything.**
Keep all the state in a single value; change it only by sending a message to one pure
function; let the view be a function of that state; push side effects (HTTP, timers)
to the edges. The illegal states stop being reachable, the update logic becomes a unit
test with no mocks, and onboarding becomes "learn one small pattern, apply it
everywhere." The rest of this guide builds that up from first principles.
---
## Part 2 — FP fundamentals for the frontend
FP here is not category theory. It is four habits that make state predictable. Each is
shown in **Elm** (a tiny, canonical functional UI language — our teaching device) and
then in **this app's real TypeScript**.
### 2a. Pure functions
A **pure function**'s output depends _only_ on its inputs, and it changes nothing else
— no network, no writing to outside variables, no clock. Same input → same output,
every time. That is what makes it trivially testable (no mocks) and easy to reason
about.
```elm
-- Elm: pure by default — there is no way to do I/O inside this
add : Int -> Int -> Int
add a b = a + b
```
In this app, the parsers and reducers are pure. For example
(`src/app/registratie/domain/value-objects/uren.ts`):
```ts
export function parseUren(raw: string): Result<string, Uren> {
const t = raw.trim();
const n = Number(t);
if (t === '' || !Number.isInteger(n) || n < 0) {
return err('Vul een geheel aantal in (0 of meer).');
}
return ok(n as Uren);
}
```
Give it `"4160"`, you always get the same `ok(4160)`. No surprises. **Why it helps:**
its unit test is one line per case and never flakes.
### 2b. Immutability
Never mutate a value in place; produce a **new** value instead. The spread `{ ...s, x }`
copies the old fields and overrides one.
```elm
-- Elm: { model | count = model.count + 1 } makes a NEW record
```
This app's reducers always return a fresh object — e.g.
`src/app/herregistratie/domain/herregistratie.machine.ts`:
```ts
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, draft: { ...s.draft, [key]: value } };
}
```
**Why it helps:** because old states are never overwritten, back-navigation and
"resume where you left off" are free (the previous value still exists), and Angular's
change detection can tell something changed by identity. Time-travel/replay is possible
_because_ nothing is destroyed.
### 2c. Unidirectional data flow
Data flows **down**; events flow **up**; there is exactly one loop. A view never reaches
sideways to mutate another component's state — it emits an event, which becomes a
message, which goes through the one update function, which produces the next state,
which flows back down. Part 3 makes this loop concrete.
### 2d. Modelling state with types (make illegal states unrepresentable)
Two kinds of type do most of the work:
- **Product type** — a record that holds several things _at once_ (`interface Draft { uren; jaren; punten }`).
- **Sum type / discriminated union** — a value that is _exactly one of_ several
labelled shapes, where a `tag` says which, and **each shape carries only the data that
makes sense for it**.
The decisive move is choosing types so that **illegal states can't be written down**.
Compare three booleans (2³ = 8 combinations, most nonsense) with one union of the 4 real
states — see [`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans)
for the full `RemoteData` treatment and diagram. The wizard's own Model is the same
idea (`herregistratie.machine.ts`):
```ts
export type WizardState =
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
```
Because `step` and `errors` exist **only** on `Editing`, and `Submitting`/`Submitted`
carry already-validated `Valid` data and _no_ error field, "submitting while a field is
invalid" or "success screen with errors still set" cannot be constructed. The bug class
is gone at compile time.
> **FP term — sum type / discriminated union:** one value that is one-of-several
> labelled shapes. The `tag` discriminates; the compiler then knows which fields exist.
### 2e. Side effects at the edges (functional core, imperative shell)
A **side effect** is anything beyond computing a return value: HTTP, timers,
`localStorage`, focus. Pure code can't do them. So we keep a **pure core** (parsers,
reducers, `visibleSteps`) and push every effect to a thin **imperative shell** (the
Angular component/service). The core decides _what the state is_; the shell _goes and
does things_, then feeds the result back in as a message. Part 4d shows exactly how.
> **FP term — pure core / imperative shell:** all decisions in pure functions; all I/O
> in a thin outer layer that calls them.
---
## Part 3 — The Elm Architecture (TEA)
TEA is four pieces and one loop. In Elm:
- **Model** — the single source of truth (all your state, one value).
- **Msg** — every thing that can happen, as a union.
- **`update : Msg -> Model -> Model`** — the _only_ place state changes; pure.
- **`view : Model -> Html Msg`** — a pure function of the state that emits messages.
```elm
type alias Model = { count : Int }
type Msg = Increment | Decrement
update : Msg -> Model -> Model
update msg model =
case msg of
Increment -> { model | count = model.count + 1 }
Decrement -> { model | count = model.count - 1 }
view : Model -> Html Msg
view model =
div []
[ button [ onClick Decrement ] [ text "-" ]
, text (String.fromInt model.count)
, button [ onClick Increment ] [ text "+" ]
]
```
The runtime wires it into a loop:
```mermaid
graph LR
S["Model (state)"] --> V["view(Model)"]
V -->|"user event"| M["Msg"]
M --> U["update(Msg, Model) — PURE"]
U -->|"next Model"| S
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
class S,V,M,U l;
```
**Effects** don't break the loop. In Elm, `update` can return a `Cmd` (a _description_
of an effect — "go do this HTTP call"); the runtime performs it and feeds the result
back in as another `Msg`. **Subscriptions** are the same for incoming events (time,
websockets). The key property survives: `update` itself stays pure — it only ever
_describes_ effects, never performs them. This app does the same with a small twist
(Part 4d): the effect lives in the component, and its outcome is dispatched as a `Msg`.
---
## Part 4 — How we do TEA in Angular with signals
This app implements TEA with Angular **signals**. There is no extra state library. One
important shape difference from textbook Elm: **state is per-wizard, not one global
Model** — each flow (`herregistratie`, `intake`, `registratie`) has its own little
store. Cross-page state that _must_ be shared lives in one root singleton
(`BigProfileStore`, see [`ARCHITECTURE.md` §2e](./ARCHITECTURE.md#2e-optimistic-update--rollback-and-shared-state-across-pages)).
### 4a. The store — TEA's runtime in ~10 lines
`src/app/shared/application/store.ts`:
```ts
export interface Store<Model, Msg> {
/** The current state, as a read-only Angular signal. */
readonly model: Signal<Model>;
/** Send a message; the model becomes update(model, msg). */
dispatch(msg: Msg): void;
}
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
): Store<Model, Msg> {
const model = signal(init);
return {
model: model.asReadonly(),
dispatch: (msg) => model.set(update(model(), msg)),
};
}
```
This _is_ the Elm runtime: a `signal` holds the Model, and `dispatch` is the only way
to change it — it runs the pure `update` and `set`s the new value.
> **Naming note (read the code, not the textbook):** the factory parameter is called
> `update` (the Elm word), but each feature exports its reducer as **`reduce`** and
> passes it in: `createStore(initial, reduce)`. "update" and "reduce" are the same role.
### 4b. Model + Msg + reduce
Mapping the four TEA pieces to real code, using the herregistratie wizard (the smallest
machine) as the example — `src/app/herregistratie/domain/herregistratie.machine.ts`:
- **Model** → `WizardState` (the discriminated union from §2d).
- **Msg** → `WizardMsg`, every event as one union:
```ts
export type WizardMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
```
- **update** → the pure `reduce(state, msg)` — no injection, no HTTP, no mutation:
```ts
export function reduce(s: WizardState, m: WizardMsg): WizardState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Seed':
return m.state;
default:
return assertNever(m); // compiler error if a Msg is unhandled
}
}
```
`assertNever` (`src/app/shared/kernel/fp.ts`) makes the switch **exhaustive**: add a new
`Msg` variant and forget to handle it, and the build fails. (`intake.machine.ts` and
`registratie-wizard.machine.ts` have larger unions, same exact shape.)
### 4c. view → template + `computed()` + `dispatch`
The container component (`src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`)
creates the store and derives view values with `computed()`:
```ts
private store = createStore<WizardState, WizardMsg>(initial, reduce);
readonly state = this.store.model; // a read-only signal of the Model
protected dispatch = this.store.dispatch; // the only way to change it
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', jaren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
```
The template is a **function of the state**: it reads those `computed()` signals and
sends messages on events — it never mutates:
```html
<app-text-input
[ngModel]="draft().uren"
(ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
...
/>
...
<app-button (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
```
That is the loop: `state → template → event → dispatch(Msg) → reduce → new state →
template`.
### 4d. Effects → a command that dispatches the outcome
`reduce` is pure, so it can't call the network. The component holds a small **command**
method. It does the impure work, then dispatches a `Msg` describing what happened — the
result re-enters through the same pure loop:
```ts
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie(); // optimistic flag (shared store)
const r = await submitHerregistratie(s.data); // the actual I/O — a Result
if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); }
else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); }
}
```
The command itself (`src/app/herregistratie/application/submit-herregistratie.ts`)
returns a `Result` — success-or-error as a value, never a thrown exception:
```ts
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
await new Promise((r) => setTimeout(r, 800));
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
return ok(undefined);
}
```
The split, in one line: **reducer = "what the new state is"; command = "go do the
thing, then say what happened."** Incoming effects (an arriving HTTP value, a
server-owned config) are wired with `effect()` and `untracked()` so the dispatch
doesn't loop on its own write — see the BRP prefill and policy-threshold effects in
`registratie-wizard.component.ts` / `intake-wizard.component.ts`.
### 4e. Because state is one value, you can watch it
Each wizard exposes `state` as a **read-only signal**, deliberately public so the
teaching page can highlight the live state. See it on the in-app showcase
(`src/app/showcase/concepts.page.ts`, route `/concepts`): section 4 lights up the
current `WizardState` among `Editing → Submitting → Submitted/Failed` as you drive the
form, and section 5 shows the intake steps re-deriving as you type.
> **Discrepancy with the PRD — open question.** The PRD refers to a dedicated "state
> debug view" / inspector. **No such feature exists** in the code today. What exists is
> the `/concepts` showcase (live state highlight) and the `?scenario=slow|loading|empty|error`
> interceptor (`src/app/shared/infrastructure/scenario.ts`) for exercising async states.
> A JSON state inspector _would be trivial here_ — single one-way state means you could
> render `JSON.stringify(state())` in a panel and watch every transition — precisely
> because of everything in Part 6. Treat building one as a future task, not documented
> reality.
---
## Part 5 — FP × atomic design (the unifying chapter)
The central idea of this guide:
> **FP and atomic design are the same principle at two scales — composition of pure
> pieces, with state pushed to the boundary.**
### 5a. Atoms & molecules _are_ view functions
A pure function maps inputs → output with no side effects. A **presentational
component** does exactly that: it maps **inputs → DOM**, emits events, and has **no
injected services, no internal mutable state, no effects**. Same inputs → same DOM.
That is referential transparency at the component scale.
In this codebase the form **atoms** (`text-input`, `radio-group`) are thin wrappers over
the design system. They take config via `input()` and — because they implement
Angular's `ControlValueAccessor` — emit changes through `[ngModel]` / `(ngModelChange)`.
The `form-field` **molecule** composes a label + projected control + error. The
`address-fields` **organism** (`src/app/registratie/ui/address-fields/address-fields.component.ts`)
composes three `form-field`s and emits with `output()`:
```ts
export class AddressFieldsComponent {
value = input.required<AdresValue>(); // data in
errors = input<AdresErrors>({}); // data in
fieldChange = output<{ key: keyof AdresValue; value: string }>(); // events out
}
```
> **Read the code, not the slogan:** "events up" has two real forms here. Design-system
> form atoms emit via `ControlValueAccessor`/`ngModel`; higher composites use `output()`.
> Both are "data down, events up" — just different Angular mechanisms.
### 5b. Composition stays pure
Molecules compose atoms; organisms compose molecules — exactly like composing pure
functions, where the composite is still pure. `address-fields` is pure because the
`form-field` and `text-input` it's built from are pure. Each atomic level only uses the
level(s) below it (see the hierarchy diagram in
[`ARCHITECTURE.md` §1](./ARCHITECTURE.md#1-the-big-picture-three-contexts-four-layers)).
### 5c. Pages / containers are the TEA runtime (the shell)
The boundary between "pure presentational" and "stateful container" **is** the
functional-core / imperative-shell line from §2e. The container (e.g.
`herregistratie-wizard.component.ts`) is where the Model signal lives, where
`dispatch`/`reduce` run, and where effects are wired. Everything below it is pure view.
### 5d. The loop, overlaid on the atomic layers
```mermaid
graph TD
subgraph shell["Container = TEA runtime (imperative shell)"]
ST["Model signal + dispatch + reduce + effects"]
end
subgraph pure["Pure presentational (functional core)"]
O["Organism (e.g. address-fields)"]
M["Molecules (form-field, async)"]
A["Atoms (text-input, radio-group, button)"]
end
ST -->|"state flows DOWN as inputs"| O
O --> M --> A
A -.->|"events flow UP (output / ngModelChange)"| O
O -.->|"(fieldChange, click)"| ST
ST -->|"dispatch(Msg) → reduce → new state"| ST
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
classDef c fill:#e8f5e9,stroke:#39870c,color:#1b5e20;
class ST l; class O,M,A c;
```
It is the identical Elm loop of Part 3 — just composed through the atomic hierarchy.
---
## Part 6 — How this reduces complexity (concrete payoffs)
Each property maps to a tangible benefit you can point at in this repo:
- **Single source of truth.** All wizard state is one `WizardState` value. To learn
every way the screen can change, you read **one** function (`reduce`). There is no
"who else mutates this?"
- **Pure, exhaustive `reduce`.** It's a unit test with **no mocks** — pass a state and a
msg, assert the next state (`herregistratie.machine.spec.ts`). `assertNever` makes the
compiler reject an unhandled `Msg`, so adding an event can't silently do nothing.
- **Illegal states won't compile.** `Submitting` carries `Valid` data and has no `errors`
field, so "submit with errors showing" is unwritable. A whole bug class disappears
before runtime — contrast the 8-state boolean soup in
[`ARCHITECTURE.md` §2a](./ARCHITECTURE.md#2a-remotedata--one-value-instead-of-three-booleans).
- **Pure presentational components.** `address-fields` is tested by inputs → DOM and
reused in two call-sites (the registratie wizard and the change-request form) with no
hidden state to surprise you.
- **Isolated effects.** Every async path is a `submit-*` command returning a `Result`,
invoked from one `runIf*` method. Async reasoning happens in one place, not sprinkled
through the view.
- **Debuggability.** One value flowing one way means you can render and watch it (§4e) —
reproduction is "set the Model to this," nothing more.
- **Onboarding.** It's one small pattern repeated everywhere. Learn it once; the recipes
below are that pattern written down for the four common tasks.
---
## Part 7 — Recipes
Each recipe follows the existing pattern and naming, and ends with the same reminder:
**this is the same loop, again.**
### Recipe A — Add an atomic component (atom / molecule / organism)
**When:** you genuinely need a new building block (not a one-off; reuse must earn it —
see [CLAUDE.md §2](../CLAUDE.md)).
**Where:** `shared/ui/` if generic; a context's `ui/` if domain-specific. Pick the level
by composition: composes nothing → **atom**; composes atoms → **molecule**; composes
molecules into a domain block → **organism**.
**Steps:** build it **pure/presentational**`input()`s for data/config, `output()`s
for events, `computed()` for derived display; **no inject, no state, no effects**. Theme
only with design tokens (no hardcoded hex — CI checks via `npm run check:tokens`).
Add a co-located `*.stories.ts` titled `Layer/Name`.
```ts
// shape — see src/app/registratie/ui/address-fields/address-fields.component.ts
export class AddressFieldsComponent {
value = input.required<AdresValue>();
errors = input<AdresErrors>({});
fieldChange = output<{ key: keyof AdresValue; value: string }>();
}
```
**Tests:** Storybook stories + the a11y addon are the UI coverage (repo convention — no
component DOM tests; pure logic gets a `.spec.ts`, presentational components don't).
_This is the same loop, again: data down via `input()`, events up via `output()`._
### Recipe B — Add state + a state update (Elm-style)
**When:** a new thing can happen to a feature's state.
**Steps:** extend the `Model` immutably; add a `Msg` variant; handle it in the pure
`reduce` returning a **new** model (keep the union exhaustive — `assertNever` guards
you); expose derived values with `computed()`; `dispatch` the `Msg` where the event
originates. Keep effects **out** of `reduce`.
```ts
// 1. Msg variant (herregistratie.machine.ts)
| { tag: 'Reset' }
// 2. reduce arm
case 'Reset': return initial;
// 3. dispatch from the view
(click)="dispatch({ tag: 'Reset' })"
```
**Tests:** `reduce(model, msg)` → expected model. Pure, no mocks
(`herregistratie.machine.spec.ts`).
_This is the same loop, again._
### Recipe C — Add a field + a validation rule
**When:** the form needs a new input with its own rule.
**Steps:** combine A and B. Add the field to the `Draft`; add/extend a `SetField`-style
`Msg`; handle it in `reduce`. Write validation as a **pure** function returning `Result`
(model it on `parseUren` / `parsePostcode`). **Derive** the error/validity with
`computed()` — don't store what you can compute. Render with the `form-field` molecule +
a field atom, and wire validity into the step/submit gating via a `computed()`.
```ts
// pure rule (value-objects/) — Result<error, branded value>
export function parsePostcode(raw: string): Result<string, Postcode> {
/* ... */
}
// reduce uses it; the view shows the message via <app-form-field [error]="...">
```
**Tests:** the parser's `.spec.ts` (each accept/reject case) + a `reduce` spec for the
new field.
_This is the same loop, again — the rule is just another pure function._
### Recipe D — Add a wizard step
**When:** a flow needs another step.
**Steps:** compose AC. Model the step's state in the Model; **derive** the visible steps
rather than storing "next" — copy `visibleSteps(answers)` from
`src/app/herregistratie/domain/intake.machine.ts`:
```ts
export function visibleSteps(a: Answers): StepId[] {
const steps: StepId[] = ['buitenland'];
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails');
steps.push('uren');
if (lageUren(a)) steps.push('scholing');
steps.push('punten', 'review');
return steps;
}
```
Build the step as a presentational component (Recipe A), composed from atoms/molecules.
Derive "can advance" from a `computed()` over the step's validity; back-navigation keeps
earlier answers for free (immutability). Effects (BRP/DUO/submit) go through `submit-*`
commands in the shell, with results dispatched as `Msg`s (Part 4d).
**Tests:** `reduce` specs for the step's messages; a story for the step component; a
`visibleSteps`/machine spec as the acceptance check (`intake.machine.spec.ts`).
_This is the same loop, again — now nested inside the wizard._
---
## Part 8 — Glossary
- **Pure function** — output depends only on inputs; no side effects. Trivial to test.
- **Immutability** — never change a value in place; produce a new one (`{ ...s, x }`).
- **Side effect** — anything beyond returning a value: HTTP, timers, `localStorage`, focus.
- **Unidirectional data flow** — data down (inputs), events up (outputs); one loop.
- **Product type** — a record holding several values at once (`interface Draft { ... }`).
- **Sum type / discriminated (tagged) union** — a value that is exactly one of several
labelled shapes; a `tag` field says which, and each shape carries only its own data.
- **Make illegal states unrepresentable** — choose types so nonsense states can't be written.
- **`Model`** — the single value holding all of a feature's state.
- **`Msg`** — a union of every event that can happen to the state.
- **`update` / `reduce`** — the one pure function mapping `(Model, Msg) → next Model`.
(This codebase calls the factory parameter `update`; features export it as `reduce`.)
- **`dispatch`** — send a `Msg`; the store runs `reduce` and updates the signal.
- **Command** — an impure function that does I/O and returns a `Result`, after which the
caller dispatches a `Msg` with the outcome.
- **`Result<E, T>`** — success-or-error as a value: `{ ok: true, value }` or `{ ok: false, error }`.
- **Value object / `Brand`** — a type whose validity is guaranteed by its parser
(e.g. `Postcode`); a `Brand<string, 'Postcode'>` is only mintable through `parsePostcode`.
- **`signal`** — Angular's reactive container for a value.
- **`computed`** — a derived signal; recomputes automatically when its inputs change.
- **Presentational (pure) component** — inputs in, events out, `computed()` for display;
no inject, no state, no effects. A view function.
- **Container component** — holds the Model signal, runs `dispatch`/`reduce`, wires
effects. The TEA runtime / imperative shell.
- **Functional core / imperative shell** — all decisions in pure functions; all I/O in a
thin outer layer.
- **TEA (The Elm Architecture)** — Model / Msg / update / view + the one-directional loop.
---
_See also:_ [`ARCHITECTURE.md`](./ARCHITECTURE.md) (reference deep-dive on RemoteData,
the store, parse-don't-validate, and the .NET backend seam) and
[ADR-0001](./architecture/0001-bff-lite-decision-dtos.md) (the BFF-lite + decision-DTO
decision). Live demo: `/concepts` in the running app.

File diff suppressed because one or more lines are too long