RD-03 moved the dashboard page to overzicht/ui/overzicht.page.ts and left four sections in registratie/ui/dashboard/. The folder was named after a page that lives in another context. A reader who opened it found four sections that are not the dashboard. The folder is now overzicht-secties/ — registratie's sections for the overzicht page. The alias does not change, because the sections stay in the registratie context. The story titles do not change, because they name the context. Five documents cited registratie/ui/dashboard.page.ts, a file that RD-03 renamed. They now name overzicht.page.ts, or the section that owns the behaviour they describe. The /dashboard route keeps its path. It is a user-visible URL. npm run ci --full passes: 67 and 45 storybook suites, 306 axe tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
631 lines
30 KiB
Markdown
631 lines
30 KiB
Markdown
# 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: two apps, cross-app libraries, "contexts", "layers"
|
|
|
|
Two Angular projects share one backend and two cross-app libraries (WP-67 — see
|
|
ADR-0002's amendment for why this is a monorepo, not two repos). Inside each app (and
|
|
each library), the code is split first by **business area** (a "bounded context" in DDD
|
|
terms), then inside each area by **layer**.
|
|
|
|
```
|
|
apps/
|
|
ssp/src/app/ Zorgverlener self-service (ADR-0002) — this doc's main subject
|
|
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 ssp context)
|
|
behandelportal/src/app/ Behandelaar backoffice (ADR-0002) — a sibling app, not covered here
|
|
auth/ its own login (employee SSO, not DigiD/BSN)
|
|
behandeling/ werkvoorraad, beoordeling (WP-64/65)
|
|
libs/
|
|
shared/src/ things every app reuses (no business logic of its own)
|
|
beheer/src/ admin/stamdata — a real bounded context, used identically by both apps
|
|
```
|
|
|
|
`showcase/` is a **sanctioned exception** to the direction rules, scoped to `apps/ssp`:
|
|
its whole point is showing multiple ssp contexts side by side, so it may import any of
|
|
them. Nothing imports `showcase`. (Enforced per-app in `.dependency-cruiser.<app>.js`;
|
|
same precedent as the `debug-state` panel — see below.)
|
|
|
|
### 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["Pages<br>overzicht.page · login.page · intake.page"]
|
|
T["Templates<br>page-shell · shell"]
|
|
O["Organisms<br>login-form · registration-table · intake-wizard"]
|
|
M["Molecules<br>form-field · data-row · async"]
|
|
A["Atoms<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) | `overzicht.page.ts` |
|
|
|
|
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
|
|
application, application may use domain, everyone (in either app) may use `libs/shared`
|
|
and `libs/beheer`. Never the other way around — `libs/shared` may not depend on
|
|
`libs/beheer` either (it stays the base), and an app may not import the other app's
|
|
source. In particular **`ui/` and `layout/` never import `infrastructure/`
|
|
directly** — they reach data through an application store or command (lint-enforced,
|
|
per app — a single merged tsconfig can't resolve both apps' `@auth/*` alias at once, so
|
|
each app is cruised separately against its own `.dependency-cruiser.<app>.js`).
|
|
The `domain/` layer imports nothing from Angular, so the business rules are plain
|
|
functions you can read and test in isolation.
|
|
|
|
Allowed direction (within `apps/ssp`): `herregistratie → registratie → libs/shared|beheer`,
|
|
`auth → libs/shared|beheer`, `brief → libs/shared|beheer` (`showcase` may read every ssp
|
|
context; see above). `apps/behandelportal` has its own analogous rule for `behandeling`/`auth`.
|
|
|
|
### Why `libs/shared` is split into layers too
|
|
|
|
- `libs/shared/src/kernel/` — tiny generic helpers (no Angular).
|
|
- `libs/shared/src/application/` — generic state tools (RemoteData, the store).
|
|
- `libs/shared/src/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
|
|
- `libs/shared/src/layout/` — page chrome (header, footer, shells) — takes each app's own nav/copy via `input()`s or an injection token rather than hardcoding one app's content.
|
|
- `libs/shared/src/infrastructure/` — the demo HTTP interceptor + the one generated API client both apps import.
|
|
|
|
Imports use path aliases so they read as direction statements:
|
|
`@shared/*`, `@beheer/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`
|
|
(ssp) — `@shared/*`, `@beheer/*`, `@auth/*`, `@behandeling/*` (behandelportal); each
|
|
app's own `tsconfig.json` declares its full alias map.
|
|
|
|
---
|
|
|
|
## 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 ✗ — legal"]
|
|
b2["loading ✗ · error ✓ · data ✗ — legal"]
|
|
b3["loading ✗ · error ✗ · data ✓ — legal"]
|
|
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
|
|
(`libs/shared/src/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
|
|
(`libs/shared/src/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
|
|
participant Store
|
|
participant Reduce
|
|
User->>View: clicks / types
|
|
View->>Store: dispatch(msg)
|
|
Store->>Reduce: reduce(model, msg) — PURE
|
|
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 `libs/shared/src/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?
|
|
`createStore` (`libs/shared/src/application/store.ts`) takes an optional **effect
|
|
map**: one handler per state tag, registered next to the reducer, run when the
|
|
store **enters** that tag:
|
|
|
|
```ts
|
|
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
|
|
Submitting: async (s, store) => {
|
|
this.profile.beginHerregistratie(); // 1. optimistic (see below)
|
|
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents }); // 2. the actual call
|
|
if (r.ok) {
|
|
store.dispatch({ tag: 'SubmitConfirmed' }); // 3. tell the reducer what happened
|
|
this.profile.confirmHerregistratie();
|
|
} else {
|
|
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
|
this.profile.rollbackHerregistratie();
|
|
}
|
|
},
|
|
});
|
|
```
|
|
|
|
Two properties follow from "run on entry", and they are the reason this replaced an
|
|
earlier idiom where a component called a hand-written submit method by hand after
|
|
every `dispatch`:
|
|
|
|
1. **Entering the state runs the effect.** A `dispatch` cannot silently skip it —
|
|
there is no separate call to forget.
|
|
2. **Double-submit protection is structural.** The effect fires only on a tag
|
|
**transition** (`Editing → Submitting`). A second `Submit` message while the
|
|
store is already in `Submitting` is a reducer no-op, so no second effect fires.
|
|
|
|
One message is exempt from this rule: `Seed`, the mount/restore message every
|
|
wizard sends on load. A `Seed` transition into `Submitting` (a resumed draft, a
|
|
Storybook story) must not trigger a submit, so `createStore` skips the effect for
|
|
it — see `store.ts` for the full contract.
|
|
|
|
So the split is: **reducer = "what the new state is", effect = "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`.
|
|
|
|
### 2g. Autosave — keystroke → model → debounced sync (not on blur)
|
|
|
|
A common assumption is "the form saves on blur." It doesn't. **Blur only marks a field
|
|
_touched_** so validation can show; it never writes the value or hits the network. In the
|
|
shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and
|
|
nothing more; the value is pushed on `(input)`, every keystroke
|
|
([`text-input.component.ts`](../../../libs/shared/src/ui/atoms/text-input/text-input.component.ts):
|
|
`(input)="onInput($event)"` → `onInput` calls `onChange`, vs `(blur)="onTouched()"`).
|
|
|
|
The real flow has two stages, neither keyed on focus:
|
|
|
|
1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches
|
|
`{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the
|
|
Model is always current, on every keystroke, while editing.
|
|
([`herregistratie-wizard.component.ts`](../../../apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
|
|
(`(ngModelChange)="dispatch({ tag: 'SetField', … })"`) →
|
|
[`herregistratie.machine.ts`](../../../apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts),
|
|
`setField`.)
|
|
2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine
|
|
`snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it
|
|
never dispatches, so it can't livelock the store). On the first save it lazily creates
|
|
the application and stamps `?aanvraag=<id>` into the URL, so a reload resumes the draft.
|
|
([`draft-sync.ts`](../../../apps/ssp/src/app/registratie/application/draft-sync.ts):
|
|
`DEBOUNCE_MS`, the `effect`, `flush` → `ApplicationsAdapter.syncDraft`.)
|
|
|
|
The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the
|
|
edit optimistically in the reducer and records an undo step, then `scheduleSave()` →
|
|
`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save`
|
|
([`brief.store.ts`](../../../apps/ssp/src/app/brief/application/brief.store.ts): `scheduleSave`, `flushSave`).
|
|
|
|
So it _feels_ like save-on-blur only because you usually stop typing when you leave a
|
|
field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not
|
|
"lost focus."** Submit is a separate, explicit action (§2d).
|
|
|
|
**The last-mile guard (leaving mid-debounce).** A debounce means an edit made in the final
|
|
<600 ms before you leave hasn't been written yet. Two seams close that window
|
|
([`pending-saves.ts`](../../../libs/shared/src/application/pending-saves.ts)): every autosave
|
|
owner (the brief/org-template root stores and each wizard's `draft-sync`) registers in a
|
|
`PendingSaves` registry, and
|
|
|
|
- **in-app navigation** — a `CanDeactivate` guard (`flushPendingGuard`, on the autosave
|
|
routes) flushes the pending write and _awaits_ it before the route changes, so the page
|
|
can't tear down with an unsaved keystroke;
|
|
- **hard close / reload** — a `beforeunload` handler fires the flush best-effort and triggers
|
|
the browser's native "unsaved changes" prompt. It is deliberately _not_ a guaranteed sync
|
|
save: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an async write
|
|
can't be promised to finish as the page unloads — the prompt lets the debounce land if the
|
|
user stays. The authoritative _submit_ path already force-flushes first, so only unsent
|
|
draft keystrokes are ever at risk.
|
|
|
|
---
|
|
|
|
## 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 `libs/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
|
|
Answering --> Submitting: Submit when all answers valid
|
|
Submitting --> Submitted: ok
|
|
Submitting --> Failed: error
|
|
Failed --> Submitting: Retry
|
|
note right of Answering
|
|
steps re-derived each time
|
|
end note
|
|
```
|
|
|
|
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.
|
|
|
|
### 6a. The request lifecycle today
|
|
|
|
The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is
|
|
no longer hand-written DTOs — it's an **NSwag-generated typed client**
|
|
([`api-client.ts`](../../../libs/shared/src/infrastructure/api-client.ts), regenerate with
|
|
`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a
|
|
`parse*` returning `Result` rather than `httpResource({ parse })`. End to end:
|
|
|
|
- **Proxy.** The app uses a relative base URL (`apiBaseUrl: ''`), so `/api` calls are
|
|
same-origin and `ng serve` proxies them to the backend on `:5000`.
|
|
([`environment.ts`](../../../libs/shared/src/environments/environment.ts),
|
|
[`proxy.conf.json`](../../../apps/ssp/proxy.conf.json)). Each app carries its own
|
|
`proxy.conf.json` — `apps/ssp` and
|
|
[`apps/behandelportal`](../../../apps/behandelportal/proxy.conf.json) — and both point at
|
|
`http://localhost:5000`. Under `docker compose up` the app is served localized (both
|
|
locales) by `scripts/serve-i18n.mjs`, which proxies `/api` to the `api` container itself
|
|
(`API_PROXY_TARGET`).
|
|
- **Client → HttpClient seam.** The NSwag client's `fetch` is routed through Angular's
|
|
`HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live:
|
|
`X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and
|
|
GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API
|
|
traffic. ([`api-client.provider.ts`](../../../libs/shared/src/infrastructure/api-client.provider.ts):
|
|
`httpClientFetch`, `provideApiClient`; registered in
|
|
[`app.config.ts`](../../../apps/ssp/src/app/app.config.ts): `provideApiClient()`.)
|
|
- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=`
|
|
toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints).
|
|
|
|
**A read (dashboard):** `<app-async [data]="store.profile()">` in
|
|
[`mijn-registratie.section.ts`](../../../apps/ssp/src/app/registratie/ui/overzicht-secties/mijn-registratie.section.ts)
|
|
(`MijnRegistratieSection`) →
|
|
[`BigProfileStore`](../../../apps/ssp/src/app/registratie/application/big-profile.store.ts) →
|
|
`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () =>
|
|
client.dashboardView() })`
|
|
([`dashboard-view.adapter.ts`](../../../apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts))
|
|
→ GET `/api/v1/dashboard-view` → `httpClientFetch` → proxy → backend → back through the
|
|
`parseDashboardView(json): Result` trust boundary → `RemoteData<DashboardView>` → rendered.
|
|
[`wat-moet-ik-regelen.section.ts`](../../../apps/ssp/src/app/registratie/ui/overzicht-secties/wat-moet-ik-regelen.section.ts)
|
|
reads the same store.
|
|
|
|
**A write (change address):** the `Submitting` effect (§2d) → `createSubmitChangeRequest`
|
|
([`submit-change-request.ts`](../../../apps/ssp/src/app/registratie/application/submit-change-request.ts))
|
|
→ `runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807
|
|
ProblemDetails → string ([`submit.ts`](../../../libs/shared/src/application/submit.ts)) →
|
|
[`change-request.adapter.ts`](../../../apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts)
|
|
→ POST `/api/v1/change-requests` → `ok(referentie)` / `err(detail)` → dispatch
|
|
`SubmitConfirmed` / `SubmitFailed`.
|
|
|
|
**Backend.** A single minimal-API host computes business decisions server-side (BFF-lite),
|
|
returns ProblemDetails on rule rejection, and dedupes replays via `Idempotency-Key`
|
|
([`Program.cs`](../../../backend/src/BigRegister.Api/Program.cs): `api.MapGet("/dashboard-view")`,
|
|
`api.MapPost("/change-requests")`).
|
|
|
|
---
|
|
|
|
## 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.
|