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,147 @@
import { Component, computed, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import type { Resource } from '@angular/core';
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ASYNC } from '@shared/ui/async/async.component';
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { RegistrationSummaryComponent } from '@registratie/ui/registration-summary/registration-summary.component';
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
import { Registration } from '@registratie/domain/registration';
import { parsePostcode } from '@registratie/domain/value-objects/postcode';
/** Minimal fake Resource so <app-async> can be driven through every state without HTTP. */
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
return { value: () => value as T, status: () => status, error: () => error, hasValue: () => value !== undefined, reload: () => {} } as unknown as Resource<T>;
}
/** Teaching showcase: each section pairs the impossible-state-permitting "before"
with the "after" where the type system rules it out. Composition-only. */
@Component({
selector: 'app-concepts-page',
imports: [
FormsModule, PageShellComponent, HeadingComponent, AlertComponent, TextInputComponent,
...ASYNC, SkeletonComponent, RegistrationSummaryComponent, HerregistratieWizardComponent,
],
styles: [`
.cols { display:flex; flex-wrap:wrap; gap:1.5rem; margin:1rem 0 2.5rem }
.col { flex:1 1 20rem; min-width:18rem }
.tag { font-weight:700; font-size:0.8rem; text-transform:uppercase; letter-spacing:0.04em }
.bad { color:var(--rhc-color-rood-500) }
.good { color:var(--rhc-color-groen-500) }
pre { background:var(--rhc-color-grijs-100,#f3f3f3); padding:1rem; border-radius:4px; overflow:auto; font-size:0.85rem }
`],
template: `
<app-page-shell heading="Onmogelijke toestanden onmogelijk maken" backLink="/dashboard">
<app-alert type="info">
Vier functionele patronen die met atomic design makkelijker te tonen zijn — telkens "fout"
(de oude vorm liet het toe) naast "goed" (het type maakt het onmogelijk).
</app-alert>
<!-- 1. Discriminated unions -->
<app-heading [level]="2">1 · Discriminated unions</app-heading>
<div class="cols">
<div class="col">
<p class="tag bad">Fout — vlakke interface</p>
<pre>{{ unionBad }}</pre>
<p class="rhc-paragraph">Een doorgehaalde registratie houdt tóch een herregistratiedatum: onmogelijke toestand.</p>
</div>
<div class="col">
<p class="tag good">Goed — sum type</p>
<app-registration-summary [reg]="doorgehaald" />
<p class="rhc-paragraph">De variant <code>Doorgehaald</code> kent geen herregistratiedatum, dus de rij bestaat simpelweg niet.</p>
</div>
</div>
<!-- 2. RemoteData fold -->
<app-heading [level]="2">2 · RemoteData fold</app-heading>
<div class="cols">
<div class="col">
<p class="tag good">Eén molecuul, vier elkaar uitsluitende toestanden</p>
<p class="tag">Loading</p>
<app-async [resource]="loadingRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template><ng-template appAsyncLoading><app-skeleton [count]="2" height="1.2rem" [delay]="0" /></ng-template></app-async>
<p class="tag">Empty</p>
<app-async [resource]="emptyRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
<p class="tag">Failure</p>
<app-async [resource]="errorRes"><ng-template appAsyncLoaded let-v>{{ v }}</ng-template></app-async>
<p class="tag">Success</p>
<app-async [resource]="successRes" [isEmpty]="isEmpty"><ng-template appAsyncLoaded let-v><ul class="rhc-unordered-list">@for (i of v; track i) {<li>{{ i }}</li>}</ul></ng-template></app-async>
</div>
<div class="col">
<p class="tag good">De exhaustieve fold</p>
<pre>{{ foldCode }}</pre>
<p class="rhc-paragraph">Een nieuwe variant toevoegen breekt de compile via <code>assertNever</code> tot je hem afhandelt.</p>
</div>
</div>
<!-- 3. Parse, don't validate -->
<app-heading [level]="2">3 · Parse, don't validate</app-heading>
<div class="cols">
<div class="col">
<p class="tag good">Smart constructor → Result</p>
<app-text-input inputId="pc" [ngModel]="raw()" (ngModelChange)="raw.set($event)" name="pc" placeholder="Typ een postcode, bijv. 1234 AB" />
</div>
<div class="col">
@if (parsed().ok) {
<p class="tag good">ok</p>
<pre>Postcode = "{{ parsed().ok ? $any(parsed()).value : '' }}"</pre>
<p class="rhc-paragraph">Een gevalideerde <code>Postcode</code> is een ander type dan een ruwe string.</p>
} @else {
<p class="tag bad">err</p>
<pre>{{ $any(parsed()).error }}</pre>
}
</div>
</div>
<!-- 4. State machine / wizard -->
<app-heading [level]="2">4 · Form als state machine</app-heading>
<div class="cols">
<div class="col">
<p class="tag bad">Fout — losse booleans</p>
<pre>{{ machineBad }}</pre>
<p class="rhc-paragraph">Niets verhindert "submitting" mét validatiefouten of een successcherm met errors.</p>
</div>
<div class="col">
<p class="tag good">Goed — één tagged union stuurt de UI</p>
<app-herregistratie-wizard />
</div>
</div>
</app-page-shell>
`,
})
export class ConceptsPage {
isEmpty = (v: string[]) => !v || v.length === 0;
doorgehaald: Registration = {
bigNummer: '19012345601', naam: 'Dr. A. (Anna) de Vries', beroep: 'Arts',
registratiedatum: '2012-09-01', geboortedatum: '1985-03-14',
status: { tag: 'Doorgehaald', doorgehaaldOp: '2024-05-01', reden: 'Op eigen verzoek' },
};
loadingRes = fakeResource<string[]>('loading');
emptyRes = fakeResource<string[]>('resolved', []);
errorRes = fakeResource<string[]>('error', undefined, new Error('Demo'));
successRes = fakeResource<string[]>('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']);
raw = signal('');
parsed = computed(() => parsePostcode(this.raw()));
unionBad = `interface Registration {
status: 'Geregistreerd' | 'Doorgehaald';
herregistratieDatum: string; // altijd aanwezig 😬
}`;
foldCode = `foldRemote(rd, {
loading: () => spinner,
empty: () => 'geen data',
failure: (e) => alert(e),
success: (v) => render(v),
}); // mist er één → compile-fout`;
machineBad = `submitting = signal(false);
submitted = signal(false);
errors = signal<...>({});
// submitting === true && errors.size > 0 ? 🤷`;
}