Files
atomic-design-poc/docs/fp-tea-atomic-design.md
Edwin van den Houdt e5a3030dca 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>
2026-06-26 17:40:38 +02:00

641 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.