RD-31 applied the symbols-not-lines rule to section 6a only. Section 2g has the same rot. Four of its five citation groups point at the wrong code: herregistratie.machine.ts L138-142 lands on `resolve`, not `setField`; draft-sync.ts L34 is a blank line; the wizard's L78 is an input attribute; brief.store.ts L157 is a comment. One link was also dead. text-input.component.ts moved to libs/shared/src/ui/atoms/ in RD-27. Every link in the document now resolves. 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>dashboard.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) | `dashboard.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/dashboard/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/dashboard/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.
|