Two backlog trees are complete: `docs/project/backlog/` (75 files, every WP done) and `docs/project/refactor-backlog-setup/` (the arc before it). Move both under `docs/project/archive/` with `git mv`, so history stays intact through `git log --follow`. `SHOWCASE-ROADMAP.md` moves with them, because it points at the now-archived backlog README. Add `docs/project/archive/README.md`. It states that these trees are historical and names the two directories that are still live. Repoint every inbound reference named in RD-30's Files table: CLAUDE.md, the root README, both backend READMEs, `LetterHtml.cs`, `a11y.mdx`, the `document-feature` and `new-ssp` skills, and the readable-codebase PLAN, README, and RD-19 ticket. Fix two upward-relative links inside the moved WP files (WP-68, WP-69) that gained a directory level and would otherwise break. Repoint `.prettierignore`'s two agent-prompt exclusions to their new path, so prettier keeps leaving those files' exact wording alone. Mark RD-30 done and check off its acceptance criteria; flip its README row to done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5.3 KiB
WP-73 — RegistrationStatus and Aanvraag as closed unions
Status: done (6bc00a9)
Phase: 12 — DDD hardening
Why
Two backend domain types still allowed illegal states, against CLAUDE.md's non-negotiable #3.
RegistrationStatus was a flat record whose own doc-comment admitted only Geregistreerd
should carry a herregistratie deadline — and noted the frontend modelled it correctly as a
discriminated union while the backend did not. It also made reden nullable on all three
variants where the FE requires it on two.
Aanvraag was a mutable EF class with 14 public setters. Its StatusAt carried five
Referentie! null-forgiving derefs plus a SubmittedAt!.Value — the compiler saying out loud
that "Submitted ⇒ Referentie != null" was convention, not type. WP-68 left it mutable
deliberately; WP-70/71 bought most of the safety with a test-only builder, which was itself a
hand-rolled prototype of the union this WP builds for real.
Decisions (pre-made)
- Full union, not private setters. The cheaper option (flip 14 setters to
private set, 3 files, no migration) was rejected in favour of the honest modelling. RegistrationStatus→ abstract record + three sealed variants behind a private base ctor. Chosen over WP-68's static-factory shape (AanvraagStatus) because with only 4 read sites the abstract record is affordable and makes reading safe too, not just construction.Aanvraag→Concept | Submitted | Decided(withDecidedfurther split intoGoedgekeurd | Afgewezen | MeerInfoGevraagd), the EF row demoted toAanvraagEntitybehind a two-way mapper.- The
(Owner, Type)"at most one unsubmitted aanvraag" rule is an aggregate-set invariant — it cannot live on the entity and stays procedural inCreateConceptunder the lock. Stated in code so nobody tries to move it. - No migration, no schema change, no wire change.
Acceptance criteria
- Illegal construction is a compile error, proven not assumed. Each was attempted, the compiler error recorded, then reverted:
| Attempted illegal state | Compiler error |
|---|---|
Decided with no referentie |
CS9035: Required member 'Aanvraag.Decided.Referentie' must be set |
Geschorst with a HerregistratieDatum |
CS1739: The best overload for 'Geschorst' does not have a parameter named 'HerregistratieDatum' |
Afwijzen with no toelichting |
CS9035: Required member 'Aanvraag.Decided.Afgewezen.Toelichting' must be set |
- All five
Referentie!derefs and theSubmittedAt!.Valueare gone, not suppressed.IZaakSource.CreateZaaknarrows toAanvraag.Submitted, removing the same class of deref in bothLocalZaakSourceandOpenZaakZaakSource. redenis now required onGeschorst/Doorgehaald, matching the FE union.HerregistratieRule.IsStatusConsistentdeleted as dead code — the type now guarantees what it checked, and its test could no longer construct the illegal state it existed to catch. That failure to compile is the proof the refactor worked.- Backend 242 → 241, exactly that one deleted test. No other count change.
RegistrationStatusDtoand the application DTOs byte-identical — confirmed by diffing a live backend's/swagger/v1/swagger.jsonagainst the checked-in copy. Nogen:api.
The Draft decision (made explicitly)
ApplicationStore's doc-comment claimed Draft was "Concept only" (Draft != null ⇒ !Submitted),
but Submit never cleared it — so the invariant was violated in production. Resolved in
favour of the code matching the comment: Submitted/Decided simply have no Draft property,
so submitting drops it. Verified nothing reads a submitted aanvraag's draft — draft-sync.ts's
applyResume is the only consumer of ApplicationDetailDto.Draft and only ever resumes an
unsubmitted wizard.
Deviations
Aanvraag(EF row) renamed toAanvraagEntity. The domain union needed the bare name to matchRegistrationStatus/AanvraagStatusconventions; keeping both would make every file importing both namespaces ambiguous (CS0104). The table name is unaffected — EF derives it from theApplicationsDbSetproperty, not the CLR type.- Step invariant loosened from
0 <= StepIndex < StepCountto<=:CreateConceptproduces(0, 0)before the wizard's first draft sync, which the strict form would reject at creation. AanvraagBuilder.Decided(...)now delegates to the real union constructors, dropping its own hand-rolled toelichting guard; a one-line wrapper keeps the.Decided(...).Build()chain source-compatible for existing call sites.
Verification
cd backend && dotnet format --verify-no-changes && dotnet test BigRegister.slnx --filter "Category!=Integration"
npm run ci
Follow-ups
- Making
Besluitflow through the generated client as an enum rather than astringwould remove that FE/BE seam entirely rather than guarding it (WP-75 added the guard) — but it is a wire change.