# Architecture guide

A walkthrough of how this app is organised and, especially, **how state is
managed** — written for a developer who has _not_ done functional programming
before. No prior FP knowledge assumed. Where we use an FP idea, we explain it in
plain language first.

This is a demo of a Dutch BIG-register self-service portal (a healthcare
professional logs in, sees their registration, and can apply for
re-registration — "herregistratie").

> New to functional programming or The Elm Architecture? Start with the progressive
> learning guide [`fp-tea-atomic-design.md`](./fp-tea-atomic-design.md), which teaches
> the concepts (with Elm ↔ this-app examples) and the recipes; this document is the
> reference deep-dive it points back to.

---

## 1. The big picture: six "contexts", five "layers"

The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.

```
src/app/
  shared/            things every context reuses (no business logic of its own)
  auth/              logging in / the current session
  registratie/       the user's BIG registration + personal data
  herregistratie/    the re-registration application flow
  brief/             letter-composition teaching slice
  showcase/          a teaching page; not a real feature (may read every context)
```

`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)

### The atomic-design hierarchy, visualised

The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
into **organisms**, which fill **templates**, which become **pages**. Each level only
ever uses the level(s) below it — so anything you build is reusable by everything above.

```mermaid
graph TD
    P["<b>Pages</b><br/>dashboard.page · login.page · intake.page"]
    T["<b>Templates</b><br/>page-shell · shell"]
    O["<b>Organisms</b><br/>login-form · registration-table · intake-wizard"]
    M["<b>Molecules</b><br/>form-field · data-row · async"]
    A["<b>Atoms</b><br/>button · text-input · radio-group · alert · heading"]
    P --> T --> O --> M --> A
    classDef l fill:#e5f1fb,stroke:#007bc7,color:#00567d;
    class P,T,O,M,A l;
```

Adding the branching intake wizard needed **one new atom** (`radio-group`) and **one new
organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `button`,
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
hierarchy.

Inside a context you'll see the same five folders. They answer five different
questions:

| Layer             | Answers…                              | May import Angular? | Example here                                |
| ----------------- | ------------------------------------- | ------------------- | ------------------------------------------- |
| `domain/`         | What are the business rules and data? | **No** (pure TS)    | `registration.ts`, `registration.policy.ts` |
| `application/`    | How do we coordinate a task / state?  | Yes (signals)       | `big-profile.store.ts`                      |
| `infrastructure/` | Where does data come from?            | Yes (HTTP)          | `big-register.adapter.ts`, `brp.adapter.ts` |
| `contracts/`      | What's the FE⇄BE wire shape?          | **No** (pure DTOs)  | `dashboard-view.dto.ts`                     |
| `ui/`             | How does it look?                     | Yes (components)    | `dashboard.page.ts`                         |

**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.

Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).

### Why the `shared/` kernel is split too

- `shared/kernel/` — tiny generic helpers (no Angular).
- `shared/application/` — generic state tools (RemoteData, the store).
- `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
- `shared/layout/` — page chrome (header, footer, shells).
- `shared/infrastructure/` — the demo HTTP interceptor.

Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.

---

## 2. The state-management ideas (the important part)

Most UI bugs come from **state that can lie** — two booleans that disagree, data
that's shown while an error is also showing, a "submit" that fires while a field
is invalid. The whole strategy here is: **make those impossible by choosing
better types.** Three tools do the work.

### Why not "just signals"?

You _can_ track a network call with three signals — `isLoading`, `error`, `data`. The
problem is the **state space**: three booleans is 2³ = **8** combinations, and most are
nonsense the compiler still lets you write. A single discriminated union has **exactly
the 4 states that are real** — the illegal ones can't be expressed at all.

```mermaid
graph LR
    subgraph bad["3 booleans = 8 states (most illegal)"]
        direction TB
        b1["loading ✓ · error ✗ · data ✗  ✅"]
        b2["loading ✗ · error ✓ · data ✗  ✅"]
        b3["loading ✗ · error ✗ · data ✓  ✅"]
        b4["loading ✓ · error ✓ · data ✓  ❌ nonsense"]
        b5["loading ✓ · error ✗ · data ✓  ❌ nonsense"]
        b6["… 3 more illegal combos ❌"]
    end
    subgraph good["1 union = 4 legal states"]
        direction TB
        g1["Loading"]
        g2["Empty"]
        g3["Failure (carries error)"]
        g4["Success (carries value)"]
    end
    bad -->|"choose a better type"| good
    classDef ok fill:#e8f5e9,stroke:#39870c; classDef no fill:#fdecea,stroke:#d52b1e;
    class b1,b2,b3,g1,g2,g3,g4 ok; class b4,b5,b6 no;
```

The same argument applies to forms (a `submitting` boolean that can be true _with_
validation errors) and to the branching wizard (don't store "which step is next" — it can
drift out of sync with the answers; **derive** it instead, see §5). Signals are still the
engine underneath; we just give them types that can't lie.

### 2a. `RemoteData` — one value instead of three booleans

The naive way to track a network call:

```ts
isLoading = signal(true);
error = signal<string | null>(null);
data = signal<Thing | null>(null);
```

Three signals = eight combinations, and most are nonsense (loading **and** has
data **and** has an error?). You end up writing defensive `if`s everywhere.

Instead we use **one** value that is _exactly one of_ four shapes
(`shared/application/remote-data.ts`):

```ts
type RemoteData<E, T> =
  | { tag: 'Loading' }
  | { tag: 'Empty' }
  | { tag: 'Failure'; error: E } // only this shape has an error
  | { tag: 'Success'; value: T }; // only this shape has a value
```

This is called a **discriminated union** (a.k.a. "tagged union" or "sum type"):
a value that is one of several labelled shapes, where the `tag` tells you which.
Notice the data lives _on_ the shape — you literally cannot read `.value` unless
you're in the `Success` case, so "loaded but no data" can't be written down.

To use it, you handle every case once. The `<app-async>` component
(`shared/ui/async/async.component.ts`) does this for you: you give it a
`RemoteData` (or a raw `httpResource`) and four templates, and it shows exactly
one. There's also `foldRemote(rd, { loading, empty, failure, success })` for
doing the same in TypeScript — the compiler makes you cover all four.

```mermaid
stateDiagram-v2
    [*] --> Loading: fetch starts
    Loading --> Success: data arrived
    Loading --> Empty: arrived, but no rows
    Loading --> Failure: request failed
    Failure --> Loading: reload()
    note right of Success
        value lives ONLY here
    end note
    note right of Failure
        error lives ONLY here
    end note
```

`map2` (§2b) combines two of these into one: **Failure if either failed, Loading if either
is still loading, Success only when both succeeded** — so a page renders one state, never a
contradictory mix.

> **FP term:** a _pure function_ is one whose output depends only on its inputs
> and which changes nothing else (no network, no writing to variables outside
> it). Pure functions are easy to test and reason about. We push impure things
> (HTTP, timers) to the edges.

### 2b. Combining sources with `map2` — two services, one state

The dashboard needs data from **two** services: the BIG-register (status,
specialisms) and the BRP (name, address). Each is its own `RemoteData`. Tracking
both by hand means juggling two loading flags, two errors…

`map2` folds them into **one** `RemoteData` (`big-profile.store.ts`):

```ts
profile = computed(() =>
  map2(
    fromResource(this.registrationRes), // RemoteData from service A
    fromResource(this.personRes), // RemoteData from service B
    (registration, person) => ({ registration, person }), // runs only if BOTH succeeded
  ),
);
```

The rule baked into `map2`: the combined result is a **Failure if either
failed**, **Loading if either is still loading**, and only **Success when both
succeeded**. So the page renders one state and the combiner callback only runs
when it's safe. (`map`, `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.

```mermaid
sequenceDiagram
    actor User
    participant View as View (template)
    participant Store as createStore (signal)
    participant Reduce as reduce() — PURE
    User->>View: clicks / types
    View->>Store: dispatch(msg)
    Store->>Reduce: reduce(model, msg)
    Reduce-->>Store: next model
    Store-->>View: signal updates → re-render
    Note over Reduce: the ONLY place state changes;<br/>no HTTP, no timers, no mutation
```

Side effects (HTTP) sit _outside_ this loop: a command does the I/O, then `dispatch`es a
message describing the outcome (§2d). So the reducer stays pure and testable.

The wizard (`herregistratie/domain/herregistratie.machine.ts`) is the clearest
example. Its Model is a discriminated union:

```ts
type WizardState =
  | { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: {...} }
  | { tag: 'Submitting'; data: Valid }   // carries ONLY validated data
  | { tag: 'Submitted';  data: Valid }
  | { tag: 'Failed';     data: Valid; error: string };
```

Because `step` and `errors` exist _only_ on `Editing`, and the other states
carry already-validated `data`, "submitting with validation errors showing" is
not expressible. The messages and the pure reducer:

```ts
type WizardMsg =
  | { tag: 'SetField'; key; value }
  | { tag: 'Next' }
  | { tag: 'Back' }
  | { tag: 'Submit' }
  | { tag: 'Retry' }
  | { tag: 'SubmitConfirmed' }
  | { tag: 'SubmitFailed'; error };

function reduce(state, msg) {
  /* returns the next state; no side effects */
}
```

The component (`herregistratie-wizard.component.ts`) wires it to a signal with
the tiny helper in `shared/application/store.ts`:

```ts
private store = createStore(initial, reduce);
state = this.store.model;        // a read-only signal of the current Model
dispatch = this.store.dispatch;  // send a Msg
```

In the template you don't mutate anything — you send messages:
`(click)="dispatch({ tag: 'Back' })"`.

### 2d. Side effects (HTTP) without polluting the reducer

`reduce` is pure — it must not call the network. So how does a submit happen?
The component has a small **command** method that does the impure work and then
sends messages describing the outcome:

```ts
async runIfSubmitting() {
  if (this.state().tag !== 'Submitting') return;
  this.profile.beginHerregistratie();              // 1. optimistic (see below)
  const r = await submitHerregistratie(s.data);    // 2. the actual call
  if (r.ok) { this.dispatch({ tag: 'SubmitConfirmed' }); this.profile.confirmHerregistratie(); }
  else      { this.dispatch({ tag: 'SubmitFailed', error: r.error }); this.profile.rollbackHerregistratie(); }
}
```

So the split is: **reducer = "what the new state is", command = "go do the thing,
then tell the reducer what happened."**

### 2e. Optimistic update + rollback, and shared state across pages

`BigProfileStore` is marked `providedIn: 'root'`, which means Angular creates
**one** instance for the whole app. Every page that injects it sees the same
signals. That single shared instance _is_ our cross-page state — no extra
library needed.

When the user submits a herregistratie:

1. **Optimistic:** `beginHerregistratie()` flips a `pendingHerregistratie`
   signal **before** the server answers. The dashboard already reads that
   signal, so it instantly shows "in behandeling" (in progress). The UI feels
   fast.
2. **On success:** `confirmHerregistratie()` clears the flag and calls
   `resource.reload()` — that re-fetches the registration so the screen shows the
   real, updated server data. ("Invalidation": throw away the stale copy, fetch
   fresh.)
3. **On failure:** `rollbackHerregistratie()` clears the flag, undoing the
   optimistic guess so the UI matches reality again.

### 2f. Auth/session + the route guard

`SessionStore` (`auth/application/session.store.ts`) holds `Session | null`, also
a root singleton. `login()` is a command that calls the (mock) DigiD adapter and
stores the result. The route guard (`auth/auth.guard.ts`) just reads
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.

---

## 3. "Parse, don't validate" — value objects

A raw `string` could be anything. After you've checked a postcode is valid, the
_type_ should remember that. So we have a `Postcode` type that can only be
created by `parsePostcode`, which returns a `Result` (success-or-error)
(`registratie/domain/value-objects/`):

```ts
const r = parsePostcode(userInput);
if (r.ok)
  save(r.value); // r.value is a Postcode — guaranteed well-formed
else showError(r.error); // r.error is the message
```

Once something hands you a `Postcode`, you never re-check it. The validity is
baked into the type. Same idea for `Uren` and `BigNummer`.

> **FP term:** `Result<E, T>` is "either an error `E` or a value `T`" — a
> discriminated union with `{ ok: true, value }` or `{ ok: false, error }`. It's
> how a function reports failure without throwing.

---

## 4. How to add a new feature (recipe)

1. **Domain first.** Add the types and pure rules in the right context's
   `domain/`. No Angular. Write a `.spec.ts` next to it.
2. **Infrastructure.** If you need data, add an adapter in `infrastructure/`
   returning an `httpResource` (or a command function returning a `Result`).
3. **Application.** If there's state to coordinate, add/extend a store
   (`providedIn: 'root'` if it must be shared across pages). Model state as a
   discriminated union; change it only through a pure `update`/`reduce`.
4. **UI last.** Build the page/organism from `shared/ui` atoms. Render async
   state through `<app-async>`. Send messages; don't mutate.

If you're tempted to add a third boolean to track state — stop and model it as a
discriminated union instead.

> **Worked example — the branching intake wizard** (`herregistratie/`). Domain first:
> `intake.machine.ts` is one tagged union plus a pure `reduce` and a pure
> `visibleSteps(answers)`. A command `submit-intake.ts` does the I/O. UI last:
> `intake-wizard.component.ts` (organism) is built from `form-field`, `text-input` and the
> new `radio-group` atom; `intake.page.ts` assembles it. No new state library, no booleans.

---

## 5. Branching by _deriving_, not storing

The intake wizard (`herregistratie/domain/intake.machine.ts`) shows the most important
state-management habit: **don't store what you can derive.** Naively you'd track "which
step is next" in a field and update it by hand on every answer — and the moment an earlier
answer changes, that field is stale. Instead, the set of steps is a pure function of the
answers:

```ts
function visibleSteps(a: Answers): StepId[] {
  const steps: StepId[] = ['buitenland'];
  if (a.buitenlandGewerkt === 'ja') steps.push('buitenlandDetails'); // branch appears
  steps.push('uren');
  if (lageUren(a)) steps.push('scholing'); // branch appears
  steps.push('punten', 'review');
  return steps;
}
```

The state keeps only the raw `answers` and a numeric `cursor`; the visible step is
`visibleSteps(answers)[cursor]`. Change "buiten Nederland gewerkt?" to _ja_ and the country
question simply exists; change it back and it's gone — the cursor is clamped to the new
list. There's no synchronisation code to get wrong, and `visibleSteps` is a one-line unit
test. Answers persist to `localStorage` (an `effect` in the component) so a reload resumes
where the user left off.

```mermaid
stateDiagram-v2
    [*] --> Answering
    Answering --> Answering: SetAnswer / Next / Back<br/>(steps re-derived each time)
    Answering --> Submitting: Submit (all answers valid)
    Submitting --> Submitted: ok
    Submitting --> Failed: error
    Failed --> Submitting: Retry
```

See it live on `/concepts` (section 5) — the step list and the "stap N van M" counter
update as you type.

---

## 6. Connecting to a .NET backend

> **Implemented.** No longer hypothetical: a minimal ASP.NET Core backend now hosts
> the business rules and serves the endpoints; the FE consumes it through an
> NSwag-generated typed client. See `backend/README.md`. The text below remains as
> the rationale for _why_ only `infrastructure/` + `contracts/` had to change.

The adapters used to read static JSON (`mock/*.json`). Because `infrastructure/` is the only
layer that touches the network — the **anti-corruption boundary** — pointing the app at a
real ASP.NET API touched _only these files_. Domain, application and UI don't change.

The one concrete change per adapter: a **DTO** type matching the .NET response, a
`toDomain` mapper, and a real URL.

```ts
// infrastructure/big-register.adapter.ts

// 1) Shape exactly as ASP.NET serialises it (camelCase via the default JsonSerializer).
interface RegistrationDto {
  bigNumber: string;
  name: string;
  status: 'Registered' | 'Suspended' | 'StruckOff';
  reregistrationDate?: string;
  // …
}

// 2) Map the wire shape to our domain union (this is the anti-corruption layer).
function toDomain(dto: RegistrationDto): Registration { /* build the tagged union */ }

// 3) Same httpResource, real endpoint instead of mock/registration.json.
registrationResource() {
  return httpResource(() => `${environment.apiBaseUrl}/registrations/me`, { parse: toDomain });
}
```

Practical notes, kept lazy:

- **Base URL** via Angular environments (`environment.apiBaseUrl`); `proxy.conf.json` in dev
  to avoid CORS, or enable CORS on the .NET side for the SPA origin.
- **Auth**: send the bearer/cookie with an `HttpInterceptor` (the existing
  `scenario.interceptor.ts` shows the pattern — replace or disable it for the real API).
- **The contract**: start with **hand-written DTOs** (shown above) — zero tooling. When the
  API surface grows, generate a typed client from the .NET **OpenAPI/Swagger** document
  (e.g. NSwag) so the DTOs stay in sync automatically. Either way, keep `toDomain` as the
  single place the wire format meets our types.
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.

---

## 7. Mini-glossary

- **Pure function** — output depends only on inputs; no side effects. Easy to test.
- **Discriminated / tagged union (sum type)** — a value that is exactly one of several labelled shapes (`{ tag: 'A'; ... } | { tag: 'B'; ... }`). The `tag` says which; each shape carries only the data that makes sense for it.
- **`RemoteData`** — a tagged union for an async value: Loading / Empty / Failure / Success.
- **`Result<E,T>`** — a tagged union for success-or-error.
- **Value object** — a small type whose validity is guaranteed by its constructor (e.g. `Postcode`).
- **Reducer (`update`/`reduce`)** — the one pure function that maps `(state, message) → next state`.
- **Command** — an impure function that does I/O (HTTP, timer) and then dispatches messages with the outcome.
- **Optimistic update** — show the expected result immediately, then confirm or roll back when the server answers.
- **Bounded context** — a self-contained business area with its own language and folder (`auth`, `registratie`, `herregistratie`).
- **`signal` / `computed`** — Angular's reactive values; `computed` recalculates automatically when the signals it reads change.
