A second wizard demonstrating a BRANCHING flow: the visible steps are derived from the answers by a pure `visibleSteps` function rather than stored, so answering "buiten Nederland gewerkt? -> ja" or reporting few hours adds steps and the progress denominator changes live. Same Elm-style store + RemoteData patterns as the fixed wizard; answers persist to localStorage. - intake.machine.ts: IntakeState union + Answers + visibleSteps + pure reduce (+spec) - intake-wizard organism, intake.page, submit-intake command - new radio-group atom (ControlValueAccessor) in shared/ui - /intake route + dashboard link + concepts showcase section - tighten Aantekening.type to a 'Specialisme' | 'Aantekening' union - README + ARCHITECTURE updated Verified live end-to-end (branches add steps 4->5->6, review, submit) with no console errors; build, unit tests, and Storybook all green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
20 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").
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
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["<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 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.
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 ✗ ✅"]
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:
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
(shared/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
(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.
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, 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.
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 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 shared/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:
- Optimistic:
beginHerregistratie()flips apendingHerregistratiesignal before the server answers. The dashboard already reads that signal, so it instantly shows "in behandeling" (in progress). The UI feels fast. - On success:
confirmHerregistratie()clears the flag and callsresource.reload()— that re-fetches the registration so the screen shows the real, updated server data. ("Invalidation": throw away the stale copy, fetch fresh.) - 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/):
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 errorEor a valueT" — 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)
- Domain first. Add the types and pure rules in the right context's
domain/. No Angular. Write a.spec.tsnext to it. - Infrastructure. If you need data, add an adapter in
infrastructure/returning anhttpResource(or a command function returning aResult). - 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 pureupdate/reduce. - UI last. Build the page/organism from
shared/uiatoms. 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.tsis one tagged union plus a purereduceand a purevisibleSteps(answers). A commandsubmit-intake.tsdoes the I/O. UI last:intake-wizard.component.ts(organism) is built fromform-field,text-inputand the newradio-groupatom;intake.page.tsassembles 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<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
Today the adapters 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 touches 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.jsonin dev to avoid CORS, or enable CORS on the .NET side for the SPA origin. - Auth: send the bearer/cookie with an
HttpInterceptor(the existingscenario.interceptor.tsshows 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
toDomainas 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'; ... }). Thetagsays 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;computedrecalculates automatically when the signals it reads change.