Files
atomic-design-poc/docs/reference/architecture/ARCHITECTURE.md
T
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00

29 KiB

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, 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.

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.

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:

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 ifs everywhere.

Instead we use one value that is exactly one of four shapes (libs/shared/src/application/remote-data.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.

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):

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.

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 dispatches 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:

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:

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:

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:

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.

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: (input) L29 → onChange L62, vs (blur)="onTouched()" L30).

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 L78 → herregistratie.machine.ts L138-142, 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: DEBOUNCE_MS L34, effect L102-108, flush L88-98 → 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 L157-166, L192-209).

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): 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/):

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:

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.

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.

// 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, regenerate with npm run gen:api per 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, proxy.conf.json). 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: httpClientFetch L47-82, provideApiClient L86-92; registered in app.config.ts L37.)
  • 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()">BigProfileStoreDashboardViewAdapter.dashboardViewResource() = resource({ loader: () => client.dashboardView() }) (dashboard-view.adapter.ts) → GET /api/v1/dashboard-viewhttpClientFetch → proxy → backend → back through the parseDashboardView(json): Result trust boundary → RemoteData<DashboardView> → rendered.

A write (change address): runIfSubmitting() (§2d) → createSubmitChangeRequest (submit-change-request.ts) → runSubmit — the one try/catch that mints the Idempotency-Key and maps RFC-7807 ProblemDetails → string (submit.ts) → change-request.adapter.ts → POST /api/v1/change-requestsok(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: /dashboard-view L80, /change-requests L120).


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.