test: split multi-assertion specs into single-behavior tests

One behavior per test across FE machine/store specs and backend endpoint
tests, so a failure names exactly what broke.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 20:33:25 +02:00
co-authored by Claude Opus 4.8
parent 5cae44f163
commit 55a0a2d166
48 changed files with 178 additions and 34 deletions
@@ -0,0 +1,124 @@
# ADR 0001 — Frontend⇄backend: BFF-lite endpoints + decision DTOs
Status: Accepted · Date: 2026-06-26
## Problem
The frontend makes many separate calls and aggregates them itself, and business
rules are hardwired in the client. Two concrete symptoms:
- The dashboard stitched **three** independent `httpResource`s (BIG-register
registration, BRP person, notes) together client-side. Each could be
loading/erroring independently → inconsistent snapshots ("state out of sync").
- Policy was duplicated on the client: the scholing threshold (`1000`) and the
herregistratie eligibility window (`12` months) lived in frontend code. If the
backend changes a rule, the UI silently diverges — bad for governance.
Goal: **unify FE/BE policy, cut the number of calls, and make the rules
transparent/auditable — without coupling the two sides too tightly.** We own the
backend team.
## Options considered
| Option | Fewer calls? | Unifies policy? | Cost |
| -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Lowmedium |
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Mediumhigh; new infra |
GraphQL solves over/under-fetching but does not, on its own, move rules
server-side — and our problem is policy unification + drift, not field-selection
flexibility. Option 4 is option 3 with a deployment boundary added.
## Decision
**Screen-shaped ("BFF-lite") endpoints that return decision-enriched DTOs, defined
by a single shared contract. The frontend renders decisions; it does not recompute
them.** Keep it minimal: implement BFF-shaped endpoints on the backend we already
own. Promote to a separately-deployed BFF service only when a second consumer
(mobile/partner) or a team boundary demands it — not before.
### Why DTOs _decouple_ rather than couple
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
DB/domain entities straight onto the wire, so every schema change ripples to the
client. A DTO is the decoupling seam:
```
DB entity / domain model → DTO (the wire contract) → FE view model
(backend's own) (the agreed contract) (frontend's own)
```
Each side keeps its own internal model and refactors freely; only the DTO is a
deliberate, versioned change. The one coupling that remains — both sides agreeing
on the contract — is the _wanted_, reviewable seam. Manage it with **one source of
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
the governance/transparency artifact.
### Two shapes of "policy over the wire" — pick per rule
- **Config value** — for simple thresholds. Server sends the value; the FE applies
it for instant feedback; **the backend re-validates on submit as the authority.**
Example here: the scholing threshold.
- **Decision flag** — for anything non-trivial/sensitive. Server computes the
boolean (optionally with a `reason`); the FE just renders it. Example here:
herregistratie eligibility.
The frontend keeps only **format** validation (postcode shape, integer parsing) for
instant feedback — never as the authority.
## Worked example in this POC
This POC has no real backend (static mock JSON + fake submit timers), so the
"BFF output" is a static file; the `decisions` block stands in for what the backend
would compute. Two slices were implemented to demonstrate **both** policy shapes:
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
- Contract: `src/app/registratie/contracts/dashboard-view.dto.ts`
(`DashboardViewDto` = registration + person + `decisions`).
- Endpoint: `public/mock/dashboard-view.json` (one call replaces three).
- Boundary parse: `parseDashboardView()` in
`src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
untrusted shape and maps DTO → domain (hand-written; no schema lib for one
contract).
- `BigProfileStore` now derives `profile` and `decisions` from the single
validated view (was a 3-resource `map2`). One request → one consistent snapshot.
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of
calling `isHerregistratieEligible()`. That rule is now marked server-owned in
`registration.policy.ts` (kept as reference impl + unit test; FE no longer calls it).
- The unused upstream adapters/mocks (`brp.adapter.ts`, `registration.json`,
`brp.json`) were deleted — those calls live behind the BFF now.
**B. Intake scholing threshold → config value.**
- Contract: `src/app/herregistratie/contracts/intake-policy.dto.ts`.
- Endpoint: `public/mock/intake-policy.json` (`{ "scholingThreshold": 1000 }`).
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
`lageUren(a, scholingThreshold)` and validation take the value, which lives in
machine state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT`
remains only as the offline fallback.
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
## Migration sequence (for the real app)
1. Define the contract in OpenAPI/TypeSpec; generate types for FE and BE.
2. Stand up screen-shaped endpoints on the existing backend that aggregate the
upstreams and compute `decisions`.
3. Point each screen at its single endpoint; delete client-side aggregation.
4. Move each hardwired rule server-side; expose as decision flag or config value.
5. Reduce the FE to format-validation + rendering.
## Out of scope here (next steps, not built in the worked example)
- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
- Optimistic-update race fix in `BigProfileStore`
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
concurrent submits).
- Session persistence / multi-tab sync (`SessionStore` is in-memory).
- Real OpenAPI/TypeSpec codegen toolchain.
ponytail: build the pattern once on one slice; copy it across screens when the real
backend lands, rather than scaffolding all of it up front.
@@ -0,0 +1,135 @@
# ADR 0002 — User groups as actors, not bounded contexts
Status: Proposed · Date: 2026-07-01
## Problem
Today the app knows exactly one actor. `auth/domain/session.ts` is a flat
`Session { bsn, naam }`, authentication is a faked DigiD flow, and the backend has no
role model at all (only an `X-Admin: true` header seam in `Program.cs` and a stringly-typed
`Actor` on audit entries). This whole repo _is_ the **Zorgverlener** self-service portal (SSP).
We now need a second user group — **Behandelaar** (backoffice: assessing and deciding on
applications) — and want room for others later (admin, auditor, institution rep). The question
is a modelling one, not a coding one:
> How do user groups map onto our DDD structure? Is "Zorgverlener" a bounded context? Is
> "Behandelaar" a folder next to `registratie`/`herregistratie`? Where does "who may do what" live?
Getting this wrong is expensive: split the code by role and every feature smears across
"folders per persona"; lump everyone into one `users` context and it becomes a god-context.
Confirmed constraints (with the product owner):
- The backoffice is a **separate frontend application**, own audience, own deployment.
- The groups **authenticate differently**: Zorgverlener via DigiD/BSN; Behandelaar via employee SSO.
- Both act on the **same underlying aggregate** — the aanvraag/registration — but see different views.
## Options considered
| Option | Ubiquitous language respected? | Coupling | Verdict |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------- | -------- | --------- |
| 1. Split contexts **by role** (`zorgverlener/`, `behandelaar/` folders) | No — role ≠ capability; features smear across personas | High | Reject |
| 2. One catch-all **`users`/`identity`** context owning everything about people | No — becomes a god-context; mixes identity, authz, and features | High | Reject |
| 3. **Actors are personas; contexts are capabilities; identity is typed** | Yes | Low | **Adopt** |
## Decision
**A user group is an _actor_, not a bounded context.** Bounded contexts are drawn by
**ubiquitous language + capability**, never by who logs in. Concretely:
### 1. Two capability contexts, two apps, one shared backend domain
The same real-world thing is described in two different languages:
- **Zelfbediening (SSP)** — the Zorgverlener: _"ik vraag herregistratie aan"_ — eligibility, fill in
my data, upload documents, submit. **This repo.**
- **Behandeling (backoffice)** — the Behandelaar: _"ik beoordeel de aanvraag"_ — werkvoorraad,
beoordeling, besluit, meer-info-opvragen, SLA, audit. **A sibling application**, not a folder here.
Diverging verbs over the same noun is the textbook signal for **two bounded contexts**.
### 2. The aggregate is owned by the backend; the contexts integrate through it
The aanvraag/registration is the **system of record in the backend domain**. Neither frontend owns
it. They integrate _through the backend_ using the **BFF-lite decision DTOs of ADR-0001** — the same
aggregate projected into two screen-shaped views. The **aanvraag status lifecycle** is the _published
contract_ between the two contexts:
```
Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen
```
The Behandeling context **advances** this lifecycle; the SSP **reads** it. Today the SSP already holds
the seed of it — `pendingHerregistratie` in `big-profile.store.ts:53` is the first, coarsest read of
that status ("in behandeling"). As the backoffice appears, that single boolean grows into a real
status the backend publishes.
```mermaid
graph TD
subgraph FE["Frontend bounded contexts (separate apps)"]
SSP["<b>Zelfbediening (SSP)</b><br/>Zorgverlener · DigiD/BSN<br/><i>this repo</i>"]
BO["<b>Behandeling (backoffice)</b><br/>Behandelaar · employee SSO<br/><i>sibling app</i>"]
end
BE["<b>Backend domain</b><br/>aanvraag aggregate (system of record)<br/>status lifecycle · authorization"]
SSP -- "reads aanvraag status<br/>(decision DTOs, ADR-0001)" --> BE
BO -- "advances aanvraag status<br/>(decision DTOs, ADR-0001)" --> BE
classDef c fill:#e5f1fb,stroke:#007bc7,color:#00567d;
classDef d fill:#fff4e5,stroke:#e8830c,color:#8a4b00;
class SSP,BO c;
class BE d;
```
Both FE contexts are **Customer/Conformist** to the backend's published aanvraag model. This is
deliberately **not** a Shared Kernel between the two apps — coupling two audiences' codebases directly
would defeat the point of splitting them.
### 3. Separate identity from authorization
These are two concerns people habitually conflate; keeping them apart is the crux of the model.
- **Identity — "who are you, how did you log in"** → the `auth` context. Model the principal as a
**discriminated union**, the same "make illegal states unrepresentable" reflex as `RemoteData`:
```ts
type Principal =
| { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
| { kind: 'medewerker'; medewerkerId: string; naam: string; rollen: Rol[] }; // employee SSO
```
The union captures that the two actors authenticate differently and carry different identifiers —
a Behandelaar has no BSN, a Zorgverlener has no `rollen`. This replaces the flat `Session` the day a
second actor arrives.
- **Authorization — "what may you do"** → enforced at the **backend / context boundary**, where the
backend is the authority (per ADR-0001). It is _not_ a permission matrix living in `auth`. The
frontend receives only the decisions it needs to render (e.g. a `canBeoordelen` flag), exactly like
every other server-owned rule.
### 4. "Other users" slot in without inventing contexts
Admin, auditor, institution-rep are additional **`Principal` variants** or additional **`rollen` on
`medewerker`** — never a new folder-per-role. A genuinely new _bounded context_ is warranted only when
an actor brings a new **language and capability** (e.g. an "Toezicht/Handhaving" enforcement context),
not merely a new login.
## Consequences
- This repo **stays the pure SSP**. No backoffice code leaks in; no role-named folders appear.
- The backoffice ships as a **separate app** against the same backend and the same OpenAPI contract.
- The one concrete FE change when actor #2 lands is `Session → Principal` in the `auth` context; the
`authGuard`/`SessionStore` seams already localise that (`auth.guard.ts`, `session.store.ts`).
- The backend becomes the authority for the **aanvraag status lifecycle** and for **authorization**,
publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern.
- `pendingHerregistratie` is understood as a _temporary stand-in_ for a real, backend-owned status.
## Out of scope here (next steps, not built)
- Building the Behandeling backoffice application.
- Real authentication: DigiD (SSP) and employee SSO / eHerkenning (backoffice).
- The `auth` `Session → Principal` refactor — deferred until a second actor is actually introduced.
- The backend aanvraag status lifecycle + authorization endpoints/DTOs.
ponytail: this ADR draws the boundaries so nothing has to be undone later; it does **not** scaffold a
second app or a role system now. Introduce the `Principal` union and the status lifecycle when the
backoffice work actually starts — YAGNI until then.
@@ -0,0 +1,54 @@
# ADR-0003 — Adopt the CIBG Huisstijl (Bootstrap 5.2) as the design system
Status: Accepted · Date: 2026-07-02
## Context
The portal must adhere visually to the **CIBG design system** (designsystem.cibg.nl) — CIBG runs
the real BIG-register. Until now the app was themed with **Rijkshuisstijl-Community / Utrecht**
(`@rijkshuisstijl-community/components-css` + `design-tokens`), emitting `utrecht-*`/`rhc-*` classes
and consuming a `--rhc-*` CSS-custom-property token set.
CIBG Huisstijl (`@cibg/huisstijl`, v3.22.0) is **a customized build of Bootstrap 5.2.0**: standard
Bootstrap CSS classes (`btn`, `form-control`, `card`, `table`, `breadcrumb`) themed via SCSS variable
overrides, with a full `--bs-*` runtime variable surface. For SPA frameworks it is **CSS-only — no
Bootstrap JS** (the framework drives interactivity). The two systems share neither class names nor
tokens, so adopting CIBG is a re-skin of the shared component layer plus a replacement of the token
layer — not a palette swap.
## Decision
1. **Vendor the package** under `public/cibg-huisstijl/` (not an npm dep — it was delivered as files),
loaded via a `<link>` in `src/index.html` so the CSS's relative `url(../fonts|icons|images)`
references resolve at runtime. Storybook serves the same via `staticDirs`.
2. **Token bridge over token rewrite.** `src/styles.scss` redefines the app's ~54 `--rhc-*` tokens
onto CIBG values (`--bs-*` where one exists, CIBG palette hex otherwise). The `--rhc-*` names are
now an internal alias set; the _values_ are CIBG. This avoided rewriting 300+ token references and
keeps the "components reference tokens" convention intact. (`styles.scss` is exempt from
`check:tokens`, so palette hex lives in that one file only.)
3. **Re-skin atoms, keep their `input()` APIs.** Each `shared/ui` atom now emits Bootstrap/CIBG classes
(`app-button``btn btn-primary`, `text-input``form-control`, radio/checkbox → `form-check-*`);
domain pages compose the same atoms and barely changed.
4. **Hand-roll what CIBG's build drops.** CIBG omits Bootstrap's `.alert` and `.navbar`, so `app-alert`
is a small token-styled surface and the header/side-nav use `.nav` + a local blue bar. Local class
names that collide with Bootstrap components were renamed (`.card``.app-card`, badge → `.status-badge`).
5. **System-font stack; no licensed fonts.** `--bs-font-sans-serif` is overridden to `system-ui`; the
licensed RO/Rijks **text** woffs are removed from the vendored copy (CIBG icon font kept). Logo stays
a text wordmark. Interactivity stays Angular-driven (no Bootstrap JS).
## Consequences
- Wiring the design system touches `styles.scss` (token bridge), `index.html`, `angular.json`
(`public/` already copied), and `.storybook/` — plus the class strings in ~40 `shared/ui` +
`shared/layout` + a few domain components. The `@rijkshuisstijl-community/*` deps are dropped.
- `check:tokens` still guards raw hex in components; the token bridge + hand-rolled surfaces comply.
- Known benign build warning: _"Unable to locate stylesheet: /cibg-huisstijl/css/huisstijl.min.css"_
Angular's index optimizer doesn't process a `public/` stylesheet at build time. The asset is copied
and the link is preserved (verified: served 200, `.btn-primary` present); the build exits green. The
alternative (adding the CSS to `angular.json` `styles`) would force-bundle the licensed fonts we
intentionally dropped, so we accept the warning.
- Renaming the internal token names from `--rhc-*` to `--app-*` is possible later but out of scope.
- Hand-rolled components (point 4) are tracked in the **CIBG gap register**
(`src/docs/cibg-gaps.mdx`, Storybook "Foundations/CIBG Gap Register"): every deviation from the
design system carries a `// CIBG-GAP EXTENSION:` marker so it's auditable rather than silently
drifting.
+485
View File
@@ -0,0 +1,485 @@
# 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
before. No prior FP knowledge assumed. Where we use an FP idea, we explain it in
plain language first.
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: six "contexts", five "layers"
The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.
```
src/app/
shared/ things every context reuses (no business logic of its own)
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every context)
```
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
### The atomic-design hierarchy, visualised
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
into **organisms**, which fill **templates**, which become **pages**. Each level only
ever uses the level(s) below it — so anything you build is reusable by everything above.
```mermaid
graph TD
P["<b>Pages</b><br/>dashboard.page · login.page · intake.page"]
T["<b>Templates</b><br/>page-shell · shell"]
O["<b>Organisms</b><br/>login-form · registration-table · intake-wizard"]
M["<b>Molecules</b><br/>form-field · data-row · async"]
A["<b>Atoms</b><br/>button · text-input · radio-group · alert · heading"]
P --> T --> O --> M --> A
classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
class P,T,O,M,A l;
```
Adding the branching intake wizard needed **one new atom** (`radio-group`) and **one new
organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `button`,
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
hierarchy.
Inside a context you'll see the same five folders. They answer five 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` |
| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.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
application, application may use domain, everyone may use `shared`. Never the
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).
### 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.
- `shared/layout/` — page chrome (header, footer, shells).
- `shared/infrastructure/` — the demo HTTP interceptor.
Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
---
## 2. The state-management ideas (the important part)
Most UI bugs come from **state that can lie** — two booleans that disagree, data
that's shown while an error is also showing, a "submit" that fires while a field
is invalid. The whole strategy here is: **make those impossible by choosing
better types.** Three tools do the work.
### Why not "just signals"?
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.
```mermaid
graph LR
subgraph bad["3 booleans = 8 states (most illegal)"]
direction TB
b1["loading ✓ · error ✗ · data ✗ ✅"]
b2["loading ✗ · error ✓ · data ✗ ✅"]
b3["loading ✗ · error ✗ · data ✓ ✅"]
b4["loading ✓ · error ✓ · data ✓ ❌ nonsense"]
b5["loading ✓ · error ✗ · data ✓ ❌ nonsense"]
b6["… 3 more illegal combos ❌"]
end
subgraph good["1 union = 4 legal states"]
direction TB
g1["Loading"]
g2["Empty"]
g3["Failure (carries error)"]
g4["Success (carries value)"]
end
bad -->|"choose a better type"| good
classDef ok fill:#e8f5e9,stroke:#39870c; classDef no fill:#fdecea,stroke:#d52b1e;
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_
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.
### 2a. `RemoteData` — one value instead of three booleans
The naive way to track a network call:
```ts
isLoading = signal(true);
error = signal<string | null>(null);
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
(`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
```
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
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
(`shared/ui/async/async.component.ts`) does this for you: you give it a
`RemoteData` (or a raw `httpResource`) and four templates, and it shows exactly
one. There's also `foldRemote(rd, { loading, empty, failure, success })` for
doing the same in TypeScript — the compiler makes you cover all four.
```mermaid
stateDiagram-v2
[*] --> Loading: fetch starts
Loading --> Success: data arrived
Loading --> Empty: arrived, but no rows
Loading --> Failure: request failed
Failure --> Loading: reload()
note right of Success
value lives ONLY here
end note
note right of Failure
error lives ONLY here
end note
```
`map2` (§2b) combines two of these into one: **Failure if either failed, Loading if either
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
> 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.
### 2b. Combining sources with `map2` — two services, one state
The dashboard needs data from **two** services: the BIG-register (status,
specialisms) and the BRP (name, address). Each is its own `RemoteData`. Tracking
both by hand means juggling two loading flags, two errors…
`map2` folds them into **one** `RemoteData` (`big-profile.store.ts`):
```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
),
);
```
The rule baked into `map2`: the combined result is a **Failure if either
failed**, **Loading if either is still loading**, and only **Success when both
succeeded**. So the page renders one state and the combiner callback only runs
when it's safe. (`map`, `andThen` are variations on the same idea.)
### 2c. The store — "all state changes go through one pure function"
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
> the next Model.**
Why bother? Because to understand _every_ way the screen can change, you read
_one_ function. No state is mutated anywhere else.
```mermaid
sequenceDiagram
actor User
participant View as View (template)
participant Store as createStore (signal)
participant Reduce as reduce() — PURE
User->>View: clicks / types
View->>Store: dispatch(msg)
Store->>Reduce: reduce(model, msg)
Reduce-->>Store: next model
Store-->>View: signal updates → re-render
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
message describing the outcome (§2d). So the reducer stays pure and testable.
The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest
example. Its Model is a discriminated union:
```ts
type WizardState =
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: {...} }
| { tag: 'Submitting'; data: Valid } // carries ONLY validated data
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
```
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 };
function reduce(state, msg) {
/* returns the next state; no side effects */
}
```
The component (`herregistratie-wizard.component.ts`) wires it to a signal with
the tiny helper in `shared/application/store.ts`:
```ts
private store = createStore(initial, reduce);
state = this.store.model; // a read-only signal of the current Model
dispatch = this.store.dispatch; // send a Msg
```
In the template you don't mutate anything — you send messages:
`(click)="dispatch({ tag: 'Back' })"`.
### 2d. Side effects (HTTP) without polluting the reducer
`reduce` is pure — it must not call the network. So how does a submit happen?
The component has a small **command** method that does the impure work and then
sends messages describing the outcome:
```ts
async runIfSubmitting() {
if (this.state().tag !== 'Submitting') return;
this.profile.beginHerregistratie(); // 1. optimistic (see below)
const r = await submitHerregistratie(s.data); // 2. the actual call
if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); }
else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); }
}
```
So the split is: **reducer = "what the new state is", command = "go do the thing,
then tell the reducer what happened."**
### 2e. Optimistic update + rollback, and shared state across pages
`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
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
fast.
2. **On success:** `confirmHerregistratie()` clears the flag and calls
`resource.reload()` — that re-fetches the registration so the screen shows the
real, updated server data. ("Invalidation": throw away the stale copy, fetch
fresh.)
3. **On failure:** `rollbackHerregistratie()` clears the flag, undoing the
optimistic guess so the UI matches reality again.
### 2f. Auth/session + the route guard
`SessionStore` (`auth/application/session.store.ts`) holds `Session | null`, also
a root singleton. `login()` is a command that calls the (mock) DigiD adapter and
stores the result. The route guard (`auth/auth.guard.ts`) just reads
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
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
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
```
Once something hands you a `Postcode`, you never re-check it. The validity is
baked into the type. Same idea for `Uren` and `BigNummer`.
> **FP term:** `Result<E, T>` is "either an error `E` or a value `T`" — a
> discriminated union with `{ ok: true, value }` or `{ ok: false, error }`. It's
> how a function reports failure without throwing.
---
## 4. How to add a new feature (recipe)
1. **Domain first.** Add the types and pure rules in the right context's
`domain/`. No Angular. Write a `.spec.ts` next to it.
2. **Infrastructure.** If you need data, add an adapter in `infrastructure/`
returning an `httpResource` (or a command function returning a `Result`).
3. **Application.** If there's state to coordinate, add/extend a store
(`providedIn: 'root'` if it must be shared across pages). Model state as a
discriminated union; change it only through a pure `update`/`reduce`.
4. **UI last.** Build the page/organism from `shared/ui` atoms. Render async
state through `<app-async>`. Send messages; don't mutate.
If you're tempted to add a third boolean to track state — stop and model it as a
discriminated union instead.
> **Worked example — the branching intake wizard** (`herregistratie/`). Domain first:
> `intake.machine.ts` is one tagged union plus a pure `reduce` and a pure
> `visibleSteps(answers)`. A command `submit-intake.ts` does the I/O. UI last:
> `intake-wizard.component.ts` (organism) is built from `form-field`, `text-input` and the
> new `radio-group` atom; `intake.page.ts` assembles it. No new state library, no booleans.
---
## 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
step is next" in a field and update it by hand on every answer — and the moment an earlier
answer changes, that field is stale. Instead, the set of steps is a pure function of the
answers:
```ts
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
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
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
where the user left off.
```mermaid
stateDiagram-v2
[*] --> Answering
Answering --> Answering: SetAnswer / Next / Back<br/>(steps re-derived each time)
Answering --> Submitting: Submit (all answers valid)
Submitting --> Submitted: ok
Submitting --> Failed: error
Failed --> Submitting: Retry
```
See it live on `/concepts` (section 5) — the step list and the "stap N van M" counter
update as you type.
---
## 6. Connecting to a .NET backend
> **Implemented.** No longer hypothetical: a minimal ASP.NET Core backend now hosts
> the business rules and serves the endpoints; the FE consumes it through an
> NSwag-generated typed client. See `backend/README.md`. The text below remains as
> the rationale for _why_ only `infrastructure/` + `contracts/` had to change.
The adapters used to 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 touched _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.
```ts
// infrastructure/big-register.adapter.ts
// 1) Shape exactly as ASP.NET serialises it (camelCase via the default JsonSerializer).
interface RegistrationDto {
bigNumber: string;
name: string;
status: 'Registered' | 'Suspended' | 'StruckOff';
reregistrationDate?: string;
// …
}
// 2) Map the wire shape to our domain union (this is the anti-corruption layer).
function toDomain(dto: RegistrationDto): Registration { /* build the tagged union */ }
// 3) Same httpResource, real endpoint instead of mock/registration.json.
registrationResource() {
return httpResource(() => `${environment.apiBaseUrl}/registrations/me`, { parse: toDomain });
}
```
Practical notes, kept lazy:
- **Base URL** via Angular environments (`environment.apiBaseUrl`); `proxy.conf.json` in dev
to avoid CORS, or enable CORS on the .NET side for the SPA origin.
- **Auth**: send the bearer/cookie with an `HttpInterceptor` (the existing
`scenario.interceptor.ts` shows the pattern — replace or disable it for the real API).
- **The contract**: start with **hand-written DTOs** (shown above) — zero tooling. When the
API surface grows, generate a typed client from the .NET **OpenAPI/Swagger** document
(e.g. NSwag) so the DTOs stay in sync automatically. Either way, keep `toDomain` as the
single place the wire format meets our types.
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.
---
## 7. Mini-glossary
- **Pure function** — output depends only on inputs; no side effects. Easy to test.
- **Discriminated / tagged union (sum type)** — a value that is exactly one of several labelled shapes (`{ tag: 'A'; ... } | { tag: 'B'; ... }`). The `tag` says which; each shape carries only the data that makes sense for it.
- **`RemoteData`** — a tagged union for an async value: Loading / Empty / Failure / Success.
- **`Result<E,T>`** — a tagged union for success-or-error.
- **Value object** — a small type whose validity is guaranteed by its constructor (e.g. `Postcode`).
- **Reducer (`update`/`reduce`)** — the one pure function that maps `(state, message) → next state`.
- **Command** — an impure function that does I/O (HTTP, timer) and then dispatches messages with the outcome.
- **Optimistic update** — show the expected result immediately, then confirm or roll back when the server answers.
- **Bounded context** — a self-contained business area with its own language and folder (`auth`, `registratie`, `herregistratie`).
- **`signal` / `computed`** — Angular's reactive values; `computed` recalculates automatically when the signals it reads change.