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,14 @@
import { Result, ok, err } from '@shared/kernel/fp';
import { Valid } from '../domain/herregistratie.machine';
/**
* The mutation/command: send a herregistratie application to the backend.
* Returns a Result so the caller can branch on success/failure without
* try/catch. ponytail: faked with a timer; swap for a real POST when there's
* an API. The "uren must be > 0" rule lets the demo show a failure path.
*/
export async function submitHerregistratie(data: Valid): Promise<Result<string, void>> {
await new Promise((r) => setTimeout(r, 800));
if (data.uren === 0) return err('Aanvraag afgewezen: geen gewerkte uren geregistreerd.');
return ok(undefined);
}

View File

@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import { ok, err } from '@shared/kernel/fp';
import { initial, next, back, submit, resolve, reduce, WizardState } from './herregistratie.machine';
const editing1 = (uren: string, punten = ''): WizardState => ({ tag: 'Editing', step: 1, draft: { uren, punten }, errors: {} });
const editing2 = (uren: string, punten: string): WizardState => ({ tag: 'Editing', step: 2, draft: { uren, punten }, errors: {} });
describe('wizard.machine', () => {
it('next advances only when step 1 parses', () => {
expect(next(initial).tag).toBe('Editing'); // empty uren -> stays, with error
expect((next(initial) as any).errors.uren).toBeTruthy();
expect((next(editing1('4160')) as any).step).toBe(2);
});
it('submit reaches Submitting ONLY with fully valid data', () => {
expect(submit(editing2('4160', 'x')).tag).toBe('Editing'); // invalid punten -> no Submitting
const good = submit(editing2('4160', '200'));
expect(good.tag).toBe('Submitting');
expect((good as any).data).toEqual({ uren: 4160, punten: 200 });
});
it('back / resolve are no-ops from illegal states', () => {
expect(back(initial)).toBe(initial); // step 1, nothing to go back to
expect(resolve(initial, ok(undefined))).toBe(initial); // not Submitting
});
it('resolve maps Submitting to Submitted / Failed', () => {
const submitting = submit(editing2('4160', '200'));
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
});
});
describe('reduce (message-driven)', () => {
it('drives the full happy path via messages', () => {
let s: WizardState = initial;
s = reduce(s, { tag: 'SetField', key: 'uren', value: '4160' });
s = reduce(s, { tag: 'Next' });
expect(s.tag === 'Editing' && s.step).toBe(2);
s = reduce(s, { tag: 'SetField', key: 'punten', value: '200' });
s = reduce(s, { tag: 'Submit' });
expect(s.tag).toBe('Submitting');
s = reduce(s, { tag: 'SubmitConfirmed' });
expect(s.tag).toBe('Submitted');
});
it('SubmitFailed then Retry returns to Submitting with the same data', () => {
let s = reduce(reduce(editing2('4160', '200'), { tag: 'Submit' }), { tag: 'SubmitFailed', error: 'boom' });
expect(s.tag).toBe('Failed');
s = reduce(s, { tag: 'Retry' });
expect(s.tag).toBe('Submitting');
expect((s as any).data).toEqual({ uren: 4160, punten: 200 });
});
it('Seed mounts an arbitrary state', () => {
expect(reduce(initial, { tag: 'Seed', state: editing2('1', '2') }).tag).toBe('Editing');
});
});

View File

@@ -0,0 +1,110 @@
import { Result, assertNever } from '@shared/kernel/fp';
import { Uren, parseUren } from '@registratie/domain/value-objects/uren';
/** What the user is typing (raw, possibly invalid). */
export interface Draft {
uren: string;
punten: string;
}
/** What we have AFTER parsing — branded/typed, guaranteed valid. */
export interface Valid {
uren: Uren;
punten: number;
}
/**
* The whole wizard as one tagged union. `step` and `errors` exist ONLY while
* Editing; Submitting/Submitted/Failed carry a `Valid` payload and nothing else.
* So "submitting while a field is invalid" or "showing the success screen with
* errors set" are unrepresentable — the bug class is gone by construction.
*/
export type WizardState =
| { tag: 'Editing'; step: 1 | 2; draft: Draft; errors: Partial<Record<keyof Draft, string>> }
| { tag: 'Submitting'; data: Valid }
| { tag: 'Submitted'; data: Valid }
| { tag: 'Failed'; data: Valid; error: string };
export const initial: WizardState = { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} };
/** Parse every field; on success hand back a Valid, else the per-field errors. */
function validate(draft: Draft): Result<Partial<Record<keyof Draft, string>>, Valid> {
const uren = parseUren(draft.uren);
const punten = parseUren(draft.punten);
const errors: Partial<Record<keyof Draft, string>> = {};
if (!uren.ok) errors.uren = uren.error;
if (!punten.ok) errors.punten = punten.error;
if (uren.ok && punten.ok) return { ok: true, value: { uren: uren.value, punten: punten.value } };
return { ok: false, error: errors };
}
/** Step 1 → 2: only advance if the uren field parses. Illegal elsewhere = no-op. */
export function next(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 1) return s;
const uren = parseUren(s.draft.uren);
return uren.ok
? { ...s, step: 2, errors: {} }
: { ...s, errors: { uren: uren.error } };
}
export function back(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
return { ...s, step: 1, errors: {} };
}
/** Step 2 submit: parse everything; move to Submitting only with Valid data. */
export function submit(s: WizardState): WizardState {
if (s.tag !== 'Editing' || s.step !== 2) return s;
const result = validate(s.draft);
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
}
/** Resolve the async submit. Only meaningful while Submitting. */
export function resolve(s: WizardState, r: Result<string, void>): WizardState {
if (s.tag !== 'Submitting') return s;
return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error };
}
/** Update one draft field while editing; ignored in any other state. */
export function setField(s: WizardState, key: keyof Draft, value: string): WizardState {
if (s.tag !== 'Editing') return s;
return { ...s, draft: { ...s.draft, [key]: value } };
}
/**
* Every event that can happen to the wizard, as one message type. The component
* sends a WizardMsg; `reduce` decides the next state. This is the Elm
* Model+Msg+update pattern: ONE pure function describes all state changes.
*/
export type WizardMsg =
| { tag: 'SetField'; key: keyof Draft; value: string }
| { tag: 'Next' }
| { tag: 'Back' }
| { tag: 'Submit' }
| { tag: 'Retry' }
| { tag: 'SubmitConfirmed' }
| { tag: 'SubmitFailed'; error: string }
| { tag: 'Seed'; state: WizardState }; // mount a specific state (stories/showcase)
export function reduce(s: WizardState, m: WizardMsg): WizardState {
switch (m.tag) {
case 'SetField':
return setField(s, m.key, m.value);
case 'Next':
return next(s);
case 'Back':
return back(s);
case 'Submit':
return submit(s);
case 'Retry':
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
case 'SubmitConfirmed':
return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s;
case 'SubmitFailed':
return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s;
case 'Seed':
return m.state;
default:
return assertNever(m);
}
}

View File

@@ -0,0 +1,108 @@
import { Component, computed, inject, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
import { createStore } from '@shared/application/store';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { WizardState, WizardMsg, Draft, initial, reduce } from '@herregistratie/domain/herregistratie.machine';
import { submitHerregistratie } from '@herregistratie/application/submit-herregistratie';
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
Elm-style store. The UI just sends messages and folds over the state's tag —
no booleans like `submitting`/`submitted` that could contradict each other.
Submitting also flips an optimistic flag on the shared BigProfileStore, so
the dashboard shows "in behandeling" immediately. */
@Component({
selector: 'app-herregistratie-wizard',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, AlertComponent, SpinnerComponent],
template: `
@switch (state().tag) {
@case ('Editing') {
<p class="rhc-paragraph" style="color:var(--rhc-color-grijs-700)">Stap {{ step() }} van 2</p>
<form (ngSubmit)="onPrimary()" style="max-width:28rem">
@if (step() === 1) {
<app-form-field label="Gewerkte uren (afgelopen 5 jaar)" fieldId="uren" [error]="errUren()">
<app-text-input inputId="uren" [ngModel]="draft().uren" (ngModelChange)="dispatch({ tag: 'SetField', key: 'uren', value: $event })"
name="uren" [invalid]="!!errUren()" placeholder="bijv. 4160" />
</app-form-field>
<app-button type="submit" variant="primary">Volgende</app-button>
} @else {
<app-form-field label="Behaalde nascholingspunten" fieldId="punten" [error]="errPunten()">
<app-text-input inputId="punten" [ngModel]="draft().punten" (ngModelChange)="dispatch({ tag: 'SetField', key: 'punten', value: $event })"
name="punten" [invalid]="!!errPunten()" placeholder="bijv. 200" />
</app-form-field>
<div style="display:flex;gap:0.5rem">
<app-button type="button" variant="secondary" (click)="dispatch({ tag: 'Back' })">Vorige</app-button>
<app-button type="submit" variant="primary">Herregistratie aanvragen</app-button>
</div>
}
</form>
}
@case ('Submitting') {
<app-spinner /> <span>Aanvraag wordt verwerkt…</span>
}
@case ('Submitted') {
<app-alert type="ok">Uw aanvraag tot herregistratie is ontvangen.</app-alert>
}
@case ('Failed') {
<app-alert type="error">Indienen mislukt: {{ failedError() }}</app-alert>
<div style="margin-top:1rem">
<app-button variant="secondary" (click)="onRetry()">Opnieuw proberen</app-button>
</div>
}
}
`,
})
export class HerregistratieWizardComponent {
private profile = inject(BigProfileStore);
private store = createStore<WizardState, WizardMsg>(initial, reduce);
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<WizardState>(initial);
protected state = this.store.model;
protected dispatch = this.store.dispatch;
private editing = computed(() => (this.state().tag === 'Editing' ? (this.state() as Extract<WizardState, { tag: 'Editing' }>) : null));
protected step = computed(() => this.editing()?.step ?? 1);
protected draft = computed<Draft>(() => this.editing()?.draft ?? { uren: '', punten: '' });
protected errUren = computed(() => this.editing()?.errors.uren ?? '');
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
protected failedError = computed(() => (this.state().tag === 'Failed' ? (this.state() as Extract<WizardState, { tag: 'Failed' }>).error : ''));
constructor() {
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
}
onPrimary() {
const s = this.state();
if (s.tag !== 'Editing') return;
this.dispatch(s.step === 1 ? { tag: 'Next' } : { tag: 'Submit' });
this.runIfSubmitting();
}
onRetry() {
this.dispatch({ tag: 'Retry' });
this.runIfSubmitting();
}
/** The effect: when we entered Submitting, call the backend command, flip the
optimistic cross-page flag, then dispatch the result (and commit/rollback). */
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
this.profile.beginHerregistratie();
const r = await submitHerregistratie(s.data);
if (r.ok) {
this.dispatch({ tag: 'SubmitConfirmed' });
this.profile.confirmHerregistratie();
} else {
this.dispatch({ tag: 'SubmitFailed', error: r.error });
this.profile.rollbackHerregistratie();
}
}
}

View File

@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { applicationConfig } from '@storybook/angular';
import { provideHttpClient } from '@angular/common/http';
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
import { Uren } from '@registratie/domain/value-objects/uren';
const validData = { uren: 4160 as Uren, punten: 200 };
const meta: Meta<HerregistratieWizardComponent> = {
title: 'Herregistratie/Wizard',
component: HerregistratieWizardComponent,
// The wizard injects BigProfileStore (for the optimistic cross-page flag),
// which creates httpResources — so the story needs an HttpClient.
decorators: [applicationConfig({ providers: [provideHttpClient()] })],
};
export default meta;
type Story = StoryObj<HerregistratieWizardComponent>;
// Each story seeds one state of the machine — one render per union variant.
export const Step1: Story = { args: { seed: { tag: 'Editing', step: 1, draft: { uren: '', punten: '' }, errors: {} } } };
export const Step1Error: Story = {
args: { seed: { tag: 'Editing', step: 1, draft: { uren: 'abc', punten: '' }, errors: { uren: 'Vul een geheel aantal uren in (0 of meer).' } } satisfies WizardState },
};
export const Step2: Story = { args: { seed: { tag: 'Editing', step: 2, draft: { uren: '4160', punten: '' }, errors: {} } } };
export const Submitting: Story = { args: { seed: { tag: 'Submitting', data: validData } } };
export const Submitted: Story = { args: { seed: { tag: 'Submitted', data: validData } } };
export const Failed: Story = { args: { seed: { tag: 'Failed', data: validData, error: 'Netwerkfout' } } };

View File

@@ -0,0 +1,42 @@
import { Component, computed, inject } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { map } from '@shared/application/remote-data';
import { BigProfileStore } from '@registratie/application/big-profile.store';
import { isHerregistratieEligible } from '@registratie/domain/registration.policy';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
/** A whole new page built from existing building blocks. Eligibility is a pure
domain rule (registration.policy) read from the shared profile state. */
@Component({
selector: 'app-herregistratie-page',
imports: [PageShellComponent, AlertComponent, ...ASYNC, HerregistratieWizardComponent],
template: `
<app-page-shell heading="Herregistratie aanvragen" backLink="/dashboard">
<app-async [data]="eligibility()">
<ng-template appAsyncLoaded let-eligible>
@if (eligible) {
<app-alert type="info">
Uw huidige registratie verloopt binnenkort. Vraag tijdig herregistratie aan.
</app-alert>
<div style="margin-top:1.5rem">
<app-herregistratie-wizard />
</div>
} @else {
<app-alert type="warning">
Voor uw huidige registratiestatus is herregistratie niet mogelijk.
</app-alert>
}
</ng-template>
</app-async>
</app-page-shell>
`,
})
export class HerregistratiePage {
private store = inject(BigProfileStore);
// Derive a boolean RemoteData from the combined profile via map (pure).
protected eligibility = computed(() =>
map(this.store.profile(), (p) => isHerregistratieEligible(p.registration, new Date())),
);
}