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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user