Restructure into DDD bounded contexts + functional state management

Reorganise from atomic-design-only folders into bounded contexts
(auth / registratie / herregistratie) over a shared kernel, each split into
domain / application / infrastructure / ui layers. Dependencies point inward;
the domain layer is framework-free. Path aliases (@shared/@auth/@registratie/
@herregistratie) make import direction explicit.

State management (Elm-style, native TS, no new deps):
- shared/application/store.ts — createStore(init, update): pure reducer + signal
- shared/application/remote-data.ts — add map/map2/map3/andThen combinators so
  several services fold into one RemoteData; <app-async> gains an [rd] input
- registratie/application/big-profile.store.ts — root singleton combining the
  BIG-register and BRP services via map2 into one state; holds the optimistic
  herregistratie flag shared with the dashboard
- herregistratie: machine gains a WizardMsg union + pure reduce; submit is a
  command that calls infra and dispatches the result, with optimistic update +
  rollback against the shared store
- auth: SessionStore + DigiD adapter + functional route guard; login establishes
  the session, protected routes use canActivate

Rich domain: registration.policy.ts (statusColor/label, herregistratie
eligibility, invariants); BigNummer/Postcode/Uren value objects with smart
constructors. status-badge is now domain-free (colour/label inputs).

Specs for the reducer, RemoteData combinators, and eligibility policy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 07:20:13 +02:00
parent 6bd6e854c7
commit 2114514ad7
74 changed files with 841 additions and 347 deletions

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { RemoteData, map2, map } from './remote-data';
const loading: RemoteData<string, number> = { tag: 'Loading' };
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
describe('RemoteData combinators', () => {
it('map only touches Success', () => {
const times10 = (n: number) => n * 10;
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
expect(map(loading, times10)).toEqual(loading);
});
it('map2 precedence: Failure > Loading > Success', () => {
const add = (a: number, b: number) => a + b;
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
});
});

View File

@@ -0,0 +1,90 @@
import type { Resource } from '@angular/core';
import { assertNever } from '@shared/kernel/fp';
/**
* The four mutually-exclusive states of an async fetch, as a tagged union.
* Crucially the data lives ON the state: only `Failure` has an `error`, only
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
* are unrepresentable — Richard Feldman's RemoteData.
*/
export type RemoteData<E, T> =
| { tag: 'Loading' }
| { tag: 'Empty' }
| { tag: 'Failure'; error: E }
| { tag: 'Success'; value: T };
/** Project Angular's loosely-typed Resource into a RemoteData value. */
export function fromResource<T>(
r: Resource<T>,
isEmpty: (v: T) => boolean = () => false,
): RemoteData<Error | undefined, T> {
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
if (r.status() === 'loading') return { tag: 'Loading' };
if (r.hasValue()) {
const v = r.value();
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
}
return { tag: 'Loading' };
}
/** Exhaustive fold: you must handle every case, checked at compile time. */
export function foldRemote<E, T, R>(
rd: RemoteData<E, T>,
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
): R {
switch (rd.tag) {
case 'Loading':
return h.loading();
case 'Empty':
return h.empty();
case 'Failure':
return h.failure(rd.error);
case 'Success':
return h.success(rd.value);
default:
return assertNever(rd);
}
}
// --- Combinators -----------------------------------------------------------
// Let several independent async sources be treated as one. When you combine
// two streams the result is: a failure if EITHER failed, still loading if
// either is loading, empty if either is empty, and only Success when BOTH
// succeeded. Precedence: Failure > Loading > Empty > Success.
/** Transform the value inside a Success; pass other states through unchanged. */
export function map<E, A, B>(rd: RemoteData<E, A>, f: (a: A) => B): RemoteData<E, B> {
return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd;
}
/** Combine two sources into one. Use this to merge e.g. a BIG-register call
and a BRP call into a single state the page can render. */
export function map2<E, A, B, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
f: (a: A, b: B) => R,
): RemoteData<E, R> {
if (a.tag === 'Failure') return a;
if (b.tag === 'Failure') return b;
if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' };
if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' };
return { tag: 'Success', value: f(a.value, b.value) };
}
/** Combine three sources (built on map2). */
export function map3<E, A, B, C, R>(
a: RemoteData<E, A>,
b: RemoteData<E, B>,
c: RemoteData<E, C>,
f: (a: A, b: B, c: C) => R,
): RemoteData<E, R> {
return map2(map2(a, b, (x, y) => [x, y] as const), c, ([x, y], z) => f(x, y, z));
}
/** Chain a second source that depends on the first one's value. */
export function andThen<E, A, B>(
rd: RemoteData<E, A>,
f: (a: A) => RemoteData<E, B>,
): RemoteData<E, B> {
return rd.tag === 'Success' ? f(rd.value) : rd;
}

View File

@@ -0,0 +1,29 @@
import { Signal, signal } from '@angular/core';
/**
* A tiny "Elm-style" store. The whole idea: all state lives 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. Nothing else
* mutates state, so to understand the app you only read the update function.
*
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
* to test. Instead, effectful "command" functions call the network and then
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
*/
export interface Store<Model, Msg> {
/** The current state, as a read-only Angular signal. */
readonly model: Signal<Model>;
/** Send a message; the model becomes update(model, msg). */
dispatch(msg: Msg): void;
}
export function createStore<Model, Msg>(
init: Model,
update: (model: Model, msg: Msg) => Model,
): Store<Model, Msg> {
const model = signal(init);
return {
model: model.asReadonly(),
dispatch: (msg) => model.set(update(model(), msg)),
};
}