From 8eeffc3d4af4cfe87dba91116d8b7d5ae84c72b4 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Fri, 26 Jun 2026 07:20:23 +0200 Subject: [PATCH] Add architecture guide for developers new to FP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain-language walkthrough of the bounded-context layering and the state management (RemoteData, the Elm-style store, combining services, optimistic updates, value objects), with a glossary — aimed at a junior with no functional programming background. Co-Authored-By: Claude Opus 4.8 --- docs/ARCHITECTURE.md | 272 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..5d0f4b6 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,272 @@ +# 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"). + +--- + +## 1. The big picture: three "contexts", four "layers" + +The code is split first by **business area** (a "bounded context" in DDD terms), +then inside each area by **layer**. + +``` +src/app/ + shared/ things every context reuses (no business logic of its own) + auth/ logging in / the current session + registratie/ the user's BIG registration + personal data + herregistratie/ the re-registration application flow + showcase/ a teaching page; not a real feature +``` + +Inside a context you'll see the same four folders. They answer four different +questions: + +| Layer | Answers… | May import Angular? | Example here | +|------------------|---------------------------------------|---------------------|--------------| +| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` | +| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` | +| `infrastructure/`| Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` | +| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` | + +**The one rule that keeps it sane: dependencies only point *inward*.** UI may use +application, application may use domain, everyone may use `shared`. Never the +other way around. The `domain/` layer imports nothing from Angular, so the +business rules are plain functions you can read and test in isolation. + +Allowed direction: `herregistratie → registratie → shared`, `auth → shared`. + +### Why the `shared/` kernel is split too +- `shared/kernel/` — tiny generic helpers (no Angular). +- `shared/application/` — generic state tools (RemoteData, the store). +- `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register. +- `shared/layout/` — page chrome (header, footer, shells). +- `shared/infrastructure/` — the demo HTTP interceptor. + +Imports use path aliases so they read as direction statements: +`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`. + +--- + +## 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. + +### 2a. `RemoteData` — one value instead of three booleans + +The naive way to track a network call: + +```ts +isLoading = signal(true); +error = signal(null); +data = signal(null); +``` + +Three signals = eight combinations, and most are nonsense (loading **and** has +data **and** has an error?). You end up writing defensive `if`s everywhere. + +Instead we use **one** value that is *exactly one of* four shapes +(`shared/application/remote-data.ts`): + +```ts +type RemoteData = + | { 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 `` component +(`shared/ui/async/async.component.ts`) does this for you: you give it a +`RemoteData` (or a raw `httpResource`) and four templates, and it shows exactly +one. There's also `foldRemote(rd, { loading, empty, failure, success })` for +doing the same in TypeScript — the compiler makes you cover all four. + +> **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`, `map3`, `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. + +The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest +example. Its Model is a discriminated union: + +```ts +type WizardState = + | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: {...} } + | { tag: 'Submitting'; data: Valid } // carries ONLY validated data + | { tag: 'Submitted'; data: Valid } + | { tag: 'Failed'; data: Valid; error: string }; +``` + +Because `step` and `errors` exist *only* on `Editing`, and the other states +carry already-validated `data`, "submitting with validation errors showing" is +not expressible. The messages and the pure reducer: + +```ts +type WizardMsg = + | { tag: 'SetField'; key; value } | { tag: 'Next' } | { tag: 'Back' } + | { tag: 'Submit' } | { tag: 'Retry' } + | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error }; + +function reduce(state, msg) { /* returns the next state; no side effects */ } +``` + +The component (`herregistratie-wizard.component.ts`) wires it to a signal with +the tiny helper in `shared/application/store.ts`: + +```ts +private store = createStore(initial, reduce); +state = this.store.model; // a read-only signal of the current Model +dispatch = this.store.dispatch; // send a Msg +``` + +In the template you don't mutate anything — you send messages: +`(click)="dispatch({ tag: 'Back' })"`. + +### 2d. Side effects (HTTP) without polluting the reducer + +`reduce` is pure — it must not call the network. So how does a submit happen? +The component has a small **command** method that does the impure work and then +sends messages describing the outcome: + +```ts +async runIfSubmitting() { + if (this.state().tag !== 'Submitting') return; + this.profile.beginHerregistratie(); // 1. optimistic (see below) + const r = await submitHerregistratie(s.data); // 2. the actual call + if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); } + else { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); } +} +``` + +So the split is: **reducer = "what the new state is", command = "go do the thing, +then tell the reducer what happened."** + +### 2e. Optimistic update + rollback, and shared state across pages + +`BigProfileStore` is marked `providedIn: 'root'`, which means Angular creates +**one** instance for the whole app. Every page that injects it sees the same +signals. That single shared instance *is* our cross-page state — no extra +library needed. + +When the user submits a herregistratie: +1. **Optimistic:** `beginHerregistratie()` flips a `pendingHerregistratie` + signal **before** the server answers. The dashboard already reads that + signal, so it instantly shows "in behandeling" (in progress). The UI feels + fast. +2. **On success:** `confirmHerregistratie()` clears the flag and calls + `resource.reload()` — that re-fetches the registration so the screen shows the + real, updated server data. ("Invalidation": throw away the stale copy, fetch + fresh.) +3. **On failure:** `rollbackHerregistratie()` clears the flag, undoing the + optimistic guess so the UI matches reality again. + +### 2f. Auth/session + the route guard + +`SessionStore` (`auth/application/session.store.ts`) holds `Session | null`, also +a root singleton. `login()` is a command that calls the (mock) DigiD adapter and +stores the result. The route guard (`auth/auth.guard.ts`) just reads +`store.isAuthenticated()` and redirects to `/login` if you're not signed in. +Protected routes list `canActivate: [authGuard]` in `app.routes.ts`. + +--- + +## 3. "Parse, don't validate" — value objects + +A raw `string` could be anything. After you've checked a postcode is valid, the +*type* should remember that. So we have a `Postcode` type that can only be +created by `parsePostcode`, which returns a `Result` (success-or-error) +(`registratie/domain/value-objects/`): + +```ts +const r = parsePostcode(userInput); +if (r.ok) save(r.value); // r.value is a Postcode — guaranteed well-formed +else showError(r.error); // r.error is the message +``` + +Once something hands you a `Postcode`, you never re-check it. The validity is +baked into the type. Same idea for `Uren` and `BigNummer`. + +> **FP term:** `Result` is "either an error `E` or a value `T`" — a +> discriminated union with `{ ok: true, value }` or `{ ok: false, error }`. It's +> how a function reports failure without throwing. + +--- + +## 4. How to add a new feature (recipe) + +1. **Domain first.** Add the types and pure rules in the right context's + `domain/`. No Angular. Write a `.spec.ts` next to it. +2. **Infrastructure.** If you need data, add an adapter in `infrastructure/` + returning an `httpResource` (or a command function returning a `Result`). +3. **Application.** If there's state to coordinate, add/extend a store + (`providedIn: 'root'` if it must be shared across pages). Model state as a + discriminated union; change it only through a pure `update`/`reduce`. +4. **UI last.** Build the page/organism from `shared/ui` atoms. Render async + state through ``. 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. + +--- + +## 5. 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`** — 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.