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,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"
@@ -51,14 +56,14 @@ hierarchy.
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` |
| 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,20 +138,20 @@ 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
type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E } // only this shape has an error
| { tag: 'Success'; value: T }; // only this shape has a value
| { tag: 'Failure'; error: E } // only this shape has an error
| { tag: 'Success'; value: T }; // only this shape has a value
```
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.
@@ -189,9 +195,9 @@ both by hand means juggling two loading flags, two errors…
```ts
profile = computed(() =>
map2(
fromResource(this.registrationRes), // RemoteData from service A
fromResource(this.personRes), // RemoteData from service B
(registration, person) => ({ registration, person }), // runs only if BOTH succeeded
fromResource(this.registrationRes), // RemoteData from service A
fromResource(this.personRes), // RemoteData from service B
(registration, person) => ({ registration, person }), // runs only if BOTH succeeded
),
);
```
@@ -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,14 +329,15 @@ 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
else showError(r.error); // r.error is the message
if (r.ok)
save(r.value); // r.value is a Postcode — guaranteed well-formed
else showError(r.error); // r.error is the message
```
Once something hands you a `Postcode`, you never re-check it. The validity is
@@ -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
@@ -371,14 +385,14 @@ function visibleSteps(a: Answers): StepId[] {
const steps: StepId[] = ['buitenland'];
if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails'); // branch appears
steps.push('uren');
if (lageUren(a)) steps.push('scholing'); // branch appears
if (lageUren(a)) steps.push('scholing'); // branch appears
steps.push('punten', 'review');
return steps;
}
```
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.