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
Agent guide for this repo. The *why* lives in `docs/ARCHITECTURE.md` and
`docs/architecture/0001-bff-lite-decision-dtos.md`; this file is the *rules*.
When a decision below and those docs disagree, the docs win — update this file.
Agent guide for this repo. The _why_ lives in `docs/ARCHITECTURE.md`,
`docs/architecture/0001-bff-lite-decision-dtos.md`, and the learning guide
`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,
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)
### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `showcase` (teaching page, not a feature).
| Layer | Job | Angular allowed? |
|---|---|---|
| ----------------- | ----------------------------------------- | -------------------------------- |
| `domain/` | business rules + data types | **No — pure TS.** Has `.spec.ts` |
| `application/` | coordinate state/tasks (stores, commands) | yes (signals) |
| `infrastructure/` | where data comes from (HTTP adapters) | yes (HTTP) |
@@ -41,6 +44,7 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer
`shared/ui` atoms → molecules → organisms; `shared/layout` templates (`shell`,
`page-shell`); context `ui/` pages. Each level only uses levels below. A new page
should be **composition of existing blocks** — adding building blocks is the
@@ -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.
### 3. State: make illegal states unrepresentable
Default reflex — **if you're about to add a second/third boolean to track state,
model a discriminated union instead.** Three tools, all in `shared/application`:
@@ -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
`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
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).
### 4. BFF-lite + decision DTOs (ADR-0001)
`infrastructure/` is the **only** layer that touches the network — the
anti-corruption boundary. Each screen gets one screen-shaped endpoint returning a
decision-enriched DTO; **the FE renders decisions, it does not recompute business
rules.** Per rule, pick: *decision flag* (server computes the boolean — e.g.
herregistratie eligibility) or *config value* (server sends threshold, FE applies
rules.** Per rule, pick: _decision flag_ (server computes the boolean — e.g.
herregistratie eligibility) or _config value_ (server sends threshold, FE applies
for instant feedback, server re-validates as authority — e.g. scholing threshold).
FE keeps only **format** validation, never as authority.
@@ -92,6 +98,7 @@ rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-
but the FE doesn't call them.
### 5. Testing
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
spec** (reducers, combinators, `visibleSteps`, parsers, boundary `parse*` adapters).
Test the pure function directly — no Angular TestBed for domain. UI is exercised via
@@ -99,6 +106,7 @@ Storybook stories (`*.stories.ts` co-located, titled `Layer/Name`, a11y addon on
not heavy component tests.
## Conventions
- Standalone components only; no NgModules. Signal inputs (`input()`), `inject()` over
constructor DI (constructor only for `effect()`/template-ref injection).
- Angular-native control flow `@if/@for`; native `httpResource` for fetching;
@@ -113,11 +121,13 @@ not heavy component tests.
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
## Adding a feature (recipe)
Domain first (types + pure rules + spec, no Angular) → infrastructure (adapter:
`httpResource` or command returning `Result`) → application (store if shared state;
union + pure reduce) → UI last (compose `shared/ui` atoms, wrap async in `<app-async>`,
dispatch messages). Worked example: the intake wizard (`herregistratie/`).
## Out of scope (POC, don't build unprompted)
Real auth/DigiD, real backend, i18n, NgRx, licensed Rijkshuisstijl font/logo,
runtime DTO validation on every endpoint, multi-tab session sync, OpenAPI codegen.

View File

@@ -1,7 +1,7 @@
# Architecture guide
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
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
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"
@@ -52,13 +57,13 @@ Inside a context you'll see the same four folders. They answer four different
questions:
| Layer | Answers… | May import Angular? | Example here |
|------------------|---------------------------------------|---------------------|--------------|
| ----------------- | ------------------------------------- | ------------------- | ------------------------------------------- |
| `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` |
| `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` |
**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
other way around. The `domain/` layer imports nothing from Angular, so the
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`.
### Why the `shared/` kernel is split too
- `shared/kernel/` — tiny generic helpers (no Angular).
- `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.
@@ -86,7 +92,7 @@ better types.** Three tools do the work.
### 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
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.
@@ -114,7 +120,7 @@ graph LR
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
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.
@@ -132,7 +138,7 @@ data = signal<Thing | null>(null);
Three signals = eight combinations, and most are nonsense (loading **and** has
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`):
```ts
@@ -145,7 +151,7 @@ type RemoteData<E, T> =
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.
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.
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
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
> it). Pure functions are easy to test and reason about. We push impure things
> (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:
> **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
> **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
> the next Model.**
Why bother? Because to understand *every* way the screen can change, you read
*one* function. No state is mutated anywhere else.
Why bother? Because to understand _every_ way the screen can change, you read
_one_ function. No state is mutated anywhere else.
```mermaid
sequenceDiagram
@@ -226,7 +232,7 @@ sequenceDiagram
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.
The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest
@@ -240,17 +246,23 @@ type WizardState =
| { 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
not expressible. The messages and the pure reducer:
```ts
type WizardMsg =
| { tag: 'SetField'; key; value } | { tag: 'Next' } | { tag: 'Back' }
| { tag: 'Submit' } | { tag: 'Retry' }
| { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error };
| { tag: 'SetField'; key; value }
| { tag: 'Next' }
| { 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
@@ -288,10 +300,11 @@ then tell the reducer what happened."**
`BigProfileStore` is marked `providedIn: 'root'`, which means Angular creates
**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.
When the user submits a herregistratie:
1. **Optimistic:** `beginHerregistratie()` flips a `pendingHerregistratie`
signal **before** the server answers. The dashboard already reads that
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
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)
(`registratie/domain/value-objects/`):
```ts
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
```
@@ -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
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
`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
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
@@ -403,7 +417,7 @@ update as you type.
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
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
`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