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:
147
src/app/showcase/concepts.page.ts
Normal file
147
src/app/showcase/concepts.page.ts
Normal 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 ? 🤷`;
|
||||
}
|
||||
Reference in New Issue
Block a user