Files
atomic-design-poc/docs/project/archive/backlog/WP-68-ddd-aggregate-hardening.md
T
ehoandClaude Opus 5 12f17d9d73 docs: archive the finished backlogs (RD-30)
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>
2026-09-08 23:00:38 +02:00

22 KiB
Raw Blame History

WP-68 — Aggregate invariants + status modelling (architecture review remediation)

Status: done (a394950..472a49f) Phase: 12 — DDD hardening

Why

An architecture review on 2026-08-05 (bounded contexts, aggregates, CQRS, DDD/BDD test alignment, measured against this repo's own documented pattern) found the context boundaries, the FP/TEA idioms and the read/write separation to be sound — and found four defects clustered in one place: the backend's aggregate roots do not guard their own invariants, and the aanvraag status lifecycle is a computed string living in the contracts layer.

The four in this WP, in dependency order:

  • F1 — submit links client-supplied documentIds with no ownership check. Program.cs:353-356 takes document ids straight from the request body and hands them to ApplicationStore.Submit and documents.LinkToZaak; DocumentStore.Link has no owner parameter and performs no check (DocumentStore.cs:113-125). Same for SyncDraft (Program.cs:317). A caller who knows a foreign document GUID can attach another citizen's upload to their own aanvraag — where it appears on the behandelaar's beoordeling screen with its filename (Program.cs:434) and is POSTed to OpenZaak as a zaakinformatieobject on their zaak — and flips the victim's Linked = true, which permanently blocks the victim's own delete (DeleteOwnedDeleteResult.Linked). ADR-0001 is explicit that the FE holds no authority; this trusts it anyway.

  • F3 — the aanvraag status lifecycle is a computed string in Contracts/. Three compounding facts: the status is derived in Contracts/Mappers.ToStatusDto (Mappers.cs:44-63), not in the domain; Concept is not a member of AanvraagStatusTag (ApplicationStore.cs:14) but a magic string the mapper emits; and the write path reads its own guard back out of the read DTO — a.ToStatusDto(now).Tag → compare "Concept"Enum.Parse<AanvraagStatusTag> (Program.cs:466-468). This violates the repo's non-negotiable #3 ("make illegal states unrepresentable") on the backend's most important type: the status is enum + one string that is not in the enum, so Enum.Parse is a runtime throw waiting for a new tag. It is also the one genuine CQRS symptom in the codebase — a command deriving its invariant from a read projection — and it is why F2 exists: there is no domain object that could have owned the guard.

  • F2 — the besluit invariant is checked outside the write transaction. Program.cs:469 calls BeoordelingRules.CanDecide; the write happens later in ApplicationStore.RecordBesluit (ApplicationStore.cs:278-291), which takes the lock and assigns unconditionally. Two concurrent besluiten both pass the check and both write, so the second silently overwrites a terminal decision the rule exists to freeze. The codebase already documents the correct pattern three methods earlier — CreateConcept: "Race-free: the existence check and the insert share the single write gate." This is an internal inconsistency, not a missing concept.

  • F6 — a besluit rule with no home in Domain/. "Toelichting verplicht bij Afwijzen / MeerInfoOpvragen" lives inline at Program.cs:473, although BeoordelingRules' own doc-comment says the decision-recording rules were meant to land there. It therefore has no unit test, only the endpoint test Afwijzen_requires_a_toelichting.

Plus one documentation correction (F5, see Decisions — the enforcement itself is deferred to WP-69, because it needs a wire change).

The review's remaining findings are listed under "Follow-ups" and are not this WP's scope.

Read first

  • CLAUDE.md §"The decisions" #3 (make illegal states unrepresentable) and #4 (BFF-lite)
  • ADR-0001 — BFF-lite + decision DTOs
  • backend/src/BigRegister.Api/Data/ApplicationStore.cs (the Aanvraag entity, the store's lock discipline, AanvraagStatusTag, RecordBesluit)
  • backend/src/BigRegister.Api/Contracts/Mappers.cs (ToStatusDto — the logic to move)
  • backend/src/BigRegister.Api/Program.cs lines 300-500 (draft sync, submit, beoordeling GET, besluit POST)
  • backend/src/BigRegister.Api/Zgw/ZgwZaakMapper.cs (the second producer of the status DTO — easy to miss)
  • backend/src/BigRegister.Api/Data/DocumentStore.cs (Link, DeleteOwned, the existing DeleteResult enum this WP copies)
  • backend/src/BigRegister.Api/Domain/Beoordeling/BeoordelingRules.cs

Prerequisite

Commit or stash the working tree first. At review time it carried the WP-66 id-mismatch fix across 11 modified files plus the untracked backend/tests/BigRegister.Tests/BeoordelingIdMismatchTests.cs. Do not start a cross-cutting refactor on top of uncommitted work.

Decisions

Pre-made — do not relitigate.

F3 — the status type

  1. Move AanvraagStatusTag and Besluit out of Data/ApplicationStore.cs into Domain/Applications/ (namespace BigRegister.Domain.Applications). ApplicationStore.ProcessingWindow stays where it is. The original text here said to move it too "because StatusAt needs it" — but StatusAt is an instance method on Aanvraag, itself defined in ApplicationStore.cs, so it already sits in the same file/ namespace as ProcessingWindow and can reference it directly with no cross-namespace issue. Moving it would have been motion without a reason, and — found only once implementation started — ApplicationTests.cs references ApplicationStore.ProcessingWindow directly in two tests this WP's own acceptance criteria require to stay unmodified; moving the constant would have forced a choice between breaking that criterion or adding a forwarding shim for no gain. Leave it.
  2. AanvraagStatusTag is NOT given a Concept member — implemented differently, deliberately. The original text said to add Concept as the first member. That directly conflicts with this WP's own acceptance criterion that AanvraagStatusTag_covers_the_published_lifecycle (which asserts Enum.GetNames<AanvraagStatusTag>() equals exactly the five published-lifecycle names) passes unmodified — adding a sixth name breaks it. Found only once implementation started; resolved in favor of the harder constraint (the regression-net test) and a cleaner design: AanvraagStatus.Tag is AanvraagStatusTag?, null exactly for Concept. This still closes the actual finding (a magic string with no corresponding enum member, round-tripped through the DTO and Enum.Parsed) without touching the enum the test pins, and without the reduce-only "boolean + tag" shape rule #3 warns against — a nullable discriminator is the standard two-case union, not a second boolean bolted on. Ingediend is unaffected by this and is still kept reserved (see below). Keep Ingediend even though nothing produces it today (verified: neither ToStatusDto nor ZgwZaakMapper emits it) — BeoordelingRules.CanDecide accepts it, the FE's BeoordelingStatus union declares it, statusLabel has a $localize id for it, and Only_open_statuses_are_decidable tests it. Deleting it would ripple into messages.en.xlf. Mark it reserved with a comment instead.
  3. New Domain/Applications/AanvraagStatus.cs: a sealed class (not a record — no external mutation via with is wanted, and record value-equality/ToString boilerplate buys nothing for a short-lived read model) carrying AanvraagStatusTag? Tag (null = Concept) plus the same optional payload fields the DTO has (StepIndex, StepCount, Referentie, Manual, Reden), constructed only via static factories — Concept(stepIndex, stepCount), InBehandeling(referentie, manual), Goedgekeurd(referentie), Afgewezen(referentie, reden), MeerInfoGevraagd(referentie, reden). Rejected: a full abstract-record union (one subrecord per tag). It is the purer modelling, but it forces exhaustive switches at four call sites and a per-case mapper for a marginal gain over "the factories are the only construction path". Not worth the diff here.
  4. Aanvraag.StatusAt(DateTimeOffset now) — an instance method on the entity carrying the logic currently in ToStatusDto verbatim, including the "a recorded decision wins over the auto-approve computation" ordering.
  5. Mappers.ToStatusDto becomes a one-line projection of a.StatusAt(now), via a shared Mappers.ToDto(this AanvraagStatus s) extension (also used by ZgwZaakMapper — see below, point 7 — so both status producers agree on one projection): new(s.Tag?.ToString() ?? "Concept", s.StepIndex, s.StepCount, s.Referentie, s.Manual, s.Reden).
  6. AanvraagStatusDto is unchanged — Tag stays a string. This is the safety property that makes F3 an internal refactor: no wire change, no gen:api drift, no frontend change, no messages.en.xlf change. Do not "improve" the DTO in this WP.
  7. ZgwZaakMapper is the second producer and must be converted too, or the string literals survive: ToSummaryDto and ToCreatedStatusDto build AanvraagStatus via the factories and project through the same one-liner. Its coarse behaviour must not change (open/no einddatum → InBehandeling with Manual: true; closed → Goedgekeurd) — ZgwZaakMapperTests is the net.
  8. The besluit endpoint stops going through the DTO: var status = a.StatusAt(now); compare status.Tag == AanvraagStatusTag.Concept, pass status.Tag to CanDecide. The Enum.Parse at Program.cs:468 is deleted.
  9. One Enum.Parse may remain — the beoordeling GET at Program.cs:438, which parses a tag off a DTO returned by the IZaakSource seam. That is a genuine wire→domain trust boundary, not a smell. Keep exactly one, make it non-throwing for an unknown tag, and comment it as the seam boundary. Changing IZaakSource to return domain types is out of scope.

F2 — the besluit guard

ApplicationStore.RecordBesluit(string id, Besluit besluit, string? toelichting, DateTimeOffset now) returns (RecordBesluitOutcome Outcome, Aanvraag? Aanvraag) with enum RecordBesluitOutcome { Ok, NotFound, Conflict } — mirroring the existing DocumentStore.DeleteResult precedent rather than inventing a new result idiom. Inside the lock: find, StatusAt(now), CanDecideConflict if refused, then write. The endpoint drops its own pre-check and maps the outcome to 200/404/409, so there is one source of truth for the transition. The endpoint keeps its id-resolution and its Concept → 404 (both need the IZaakSource lookup the store cannot see).

F1 — document ownership

New DocumentStore.ForeignIds(IEnumerable<string> ids, string owner) returning the ids that do not resolve to a document owned by owner (returning the offending ids, not a bool, so the ProblemDetails can name them). Called in POST /applications/{id}/submit before any write, and in the draft-sync endpoint (Program.cs:317); non-empty → 400 ProblemDetails.

Endpoint-level check only. IDocumentSource.LinkToZaak keeps its current signature (two implementations, and the endpoint has now validated its input) — add a comment saying so. "A document already linked to a different aanvraag of the same owner" is not covered here; note it as a follow-up, do not build it.

F5 — narrowed to a doc fix

IntakePolicy's XML doc-comment claims "the backend re-validates on submit as the authority". It does not: the constant's only consumer is Program.cs:155, which echoes it, and both submit paths apply SubmissionRules.RejectZeroUren only. Verified cause: neither SubmitApplicationRequest(DiplomaHerkomst, Uren, Documents) nor IntakeRequest(int Uren) carries a scholing answer at all, so the server cannot re-validate without a contract change, and the wizard's answers (scholingGevolgd, puntenintake.machine.ts:26,37) never reach it. Reading them out of the opaque Draft JSON is rejected: the backend's documented posture is that the draft is opaque (AppDbContext header comment).

In this WP: correct the doc-comment to state the gap, and nothing else. The enforcement is WP-69 (a real FE+BE slice: request fields, IntakePolicy.RejectMissingScholing, wizard payload, gen:api).

Files

  • Domain/Applications/AanvraagStatus.cs (new — tag enum, Besluit, ProcessingWindow, the status record + factories)
  • Data/ApplicationStore.cs (Aanvraag.StatusAt, RecordBesluit signature + in-lock guard, enums moved out)
  • Contracts/Mappers.cs (ToStatusDto reduced to a projection)
  • Zgw/ZgwZaakMapper.cs (both producers converted)
  • Data/DocumentStore.cs (ForeignIds)
  • Domain/Beoordeling/BeoordelingRules.cs (RequiresToelichting)
  • Domain/Intake/IntakePolicy.cs (doc-comment only)
  • Program.cs (submit + draft-sync ownership checks; besluit endpoint simplified)
  • tests/BigRegister.Tests/RuleTests.cs (new AanvraagStatusTests nested class + RequiresToelichting), ApplicationTests.cs (ownership), BeoordelingTests.cs (concurrency)

No migration: no persisted column changes (BesluitStatus already stores Besluit, whose member names are unchanged).

Steps

  1. Commit/stash the WP-66 working tree (see Prerequisite).
  2. F1DocumentStore.ForeignIds + the two endpoint checks + tests. Independent of the rest; land it first so the correctness fix is not blocked by the refactor.
  3. F3 — the status type, in Decisions order 1→9. dotnet test green with AanvraagStatusTag_covers_the_published_lifecycle, AutoApprovable_flips_to_goedgekeurd_after_the_window and ZgwZaakMapperTests unchanged — those three are the regression net for the refactor.
  4. F2RecordBesluitOutcome, guard moved inside the lock, endpoint maps the outcome.
  5. F6BeoordelingRules.RequiresToelichting + unit test; endpoint calls it.
  6. T3 — the lifecycle spec that F3 makes expressible: one [Theory] over (status × besluit) → allowed/denied, asserting among others that Afgewezen → Goedgekeurd is refused as a domain statement, not only at the endpoint.
  7. F5 — correct the IntakePolicy doc-comment; open WP-69 for the enforcement.
  8. Run the full gate (see Verification).

Acceptance criteria

  • Submitting (or draft-syncing) an aanvraag with a documentId owned by another citizen is rejected with 400, and the other citizen's document remains deletable (DeleteResult.Ok). (Submitting_a_foreign_documentId_is_rejected_and_leaves_it_deletable_by_its_owner, Draft_sync_with_a_foreign_documentId_is_rejected.)
  • AanvraagStatusTag does NOT contain Concept — implemented instead as AanvraagStatus.Tag being AanvraagStatusTag?, null exactly for Concept (see Decisions §F3.2 for why this replaced the original "add Concept to the enum" instruction). No internal domain code compares a status against the "Concept" string; the one remaining comparison (Program.cs's beoordeling GET, against IZaakSource's wire DTO) is the deliberate wire-boundary exception, paired with the one allowed Enum.TryParse below.
  • Enum.Parse/TryParse<AanvraagStatusTag> appears at most once in backend/src, at the IZaakSource seam (Program.cs beoordeling GET), and does not throw on an unknown tag (Enum.TryParse there, not Enum.Parse).
  • Mappers.ToStatusDto contains no lifecycle logic — it projects Aanvraag.StatusAt(now).
  • ZgwZaakMapper constructs no AanvraagStatusDto from string literals.
  • npm run gen:api leaves no diff in backend/swagger.json or libs/shared/src/infrastructure/api-client.ts beyond F1's new 400 responses (verified — the only diff after F3 is the two .ProducesProblem(400) blocks F1 added; proof F3 changed no wire shape).
  • Two concurrent POST /beoordeling/{id}/besluit racing on the same still-open aanvraag yield exactly one 200 and one 409; the persisted status matches whichever request won (Concurrent_besluiten_on_the_same_aanvraag_yield_exactly_one_success, stable across 5 repeated runs).
  • BeoordelingRules.RequiresToelichting exists, is unit-tested (Only_a_non_approval_requires_a_toelichting), and is the only place the rule lives.
  • A [Theory]/aggregate-level test covers the transition table (A_terminal_decision_refuses_any_further_besluit, MeerInfoOpvragen_is_not_terminal_a_further_besluit_is_still_legal — via Aanvraag.StatusAt + BeoordelingRules.CanDecide, not just a bare-tag [Theory], since CanDecide doesn't vary by which besluit is attempted — see Decisions for why a literal status×besluit cross-product theory would have been redundant with Only_open_statuses_are_decidable).
  • IntakePolicy's doc-comment no longer claims server-side re-validation; WP-69 exists (docs/project/backlog/WP-69-intake-scholing-threshold-enforcement.md).

Verification

cd backend && dotnet test          # while iterating
npm run gen:api && git diff --exit-code backend/swagger.json libs/shared/src/infrastructure/api-client.ts
npm run ci                         # the full gate before pushing
npm run e2e                        # after F1/F2/F3 — needs the backend + `npm start` running

The three existing tests named in step 3 must pass unmodified; if a refactor step needs one of them changed, the refactor changed behaviour and is wrong.

Result: npm run ci passed fully green — lint, format:check, check:tokens, all four test suites, both localized builds, npm audit, backend dotnet format+dotnet test (216 passing, up from 207 at the start of this WP), snippet-generator drift, and API-client drift (only F1's new 400 responses; F3 shows zero additional wire diff, per acceptance criteria). npm run e2e could not be verified in this session: port 4200 was already occupied by an unrelated container (team-monitor-web-1, a different repo) that Playwright's local reuseExistingServer reused as if it were this app, so every test timed out waiting for a BSN field that container doesn't have — a pre-existing local port collision, not a regression (nothing in this WP touches ports/docker), and per CLAUDE.md's GREEN definition npm run e2e isn't part of the local GREEN gate regardless. Free port 4200 (or set E2E_BASE_URL) and re-run npm run e2e to close this out if end-to-end confirmation is wanted.

Out of scope

Deliberately excluded — each is a separate WP if wanted:

  • F4 — backend layer enforcement. Domain/Beoordeling/BeoordelingRules.cs and Domain/Authorization/Authz.cs import BigRegister.Api.Data (and Authz also .Contracts, returning BriefDecisionsDto), with nothing in CI checking direction — the FE has dep:check, the backend has only dotnet format + dotnet test. This WP's step 3 removes the BeoordelingRules violation as a side effect; the Authz one and the ~6-line reflection convention test are WP-70.
  • F5 enforcement → WP-69 (see Decisions).
  • F7ApplicationStore.Submit and DocumentStore.Link take separate locks with no transaction and no compensation; a link failure leaves a submitted aanvraag whose documents are still deletable. Same failure class WP-60 closed for ZGW and left open locally. Fix is to route it through the existing divergence flag + audit row, not to merge the aggregates.
  • F8 — pushing invariants from the static stores onto Aanvraag as instance methods (TryRecordBesluit). This WP does the two that matter; the general move can wait.
  • F9Authz spans five contexts and its four admin gates are byte-identical role == Admin checks with no direct unit test and no test denying Approver.
  • F10 — splitting Program.cs (917 lines, 50 endpoints). Deliberately deferred and flagged as risky: OrgAdmin, StamdataAdmin, Beoordelen, Submit and AuditAuthz are non-static local functions (Program.cs:756+) that every endpoint lambda closes over, so splitting means converting all of them to static helpers with explicit dependencies across all 50 registrations — with the deliberate authz ordering (Forbidden before Conflict) as the thing that breaks silently. Lowest value of the review's findings; do it alone, with tests as the net, or not at all.
  • F11 — three FE adapter fetch idioms; two loaders throw instead of returning Result; runSubmit (which mints an Idempotency-Key) is used for reads in brief.adapter.ts:56, org-template.adapter.ts:39,51, stamdata.adapter.ts:27,42. Fix is runQuery/runCommand over one shared try/catch, ~10 lines.
  • T2 — ~54 FE it() titles are named after Msg tags ('SetField updates the draft', 'SubmitConfirmed maps Submitting to Submitted'), against bdd.mdx rule 3. Titles only.
  • T5 — named coverage gaps: OrgTemplateRules.RejectDraft (both identity branches, no margin boundary test), the four Authz admin gates, DocumentRules.CategoriesFor's herregistratie/org-template branches, SubmissionRules.NewReference, FE isStatusConsistent (tested on the backend, never on the FE), the FE herregistratie window boundary, and the FE/BE margin constants which mirror each other with no contract test.
  • T6 — trust-boundary describe naming has three dialects; 7 parse* specs use none.
  • ADR-0006 "CQS without CQRS" — the review's learning deliverable: the read/write separation already present, why the emit-and-enforce rule (one function feeding both the decision flag and the enforcement) makes a read/write stack split actively harmful here, and WP-60's deferred outbox as the documented trigger that would change the answer. Prose only, no runtime code.
  • Anything CQRS-mechanical: MediatR, handler classes, a separate read store, event sourcing, repositories/unit-of-work, Gherkin/Reqnroll. All explicitly rejected by the review.

Risks

  • Scope creep on F3. The temptation is to "fix" AanvraagStatusDto into a proper wire union while in there. That turns a zero-diff internal refactor into an FE + messages.en.xlf + gen:api change. The acceptance criterion "gen:api leaves no diff" exists to catch it.
  • Missing the second producer. ZgwZaakMapper is easy to overlook because it lives under Zgw/, not Contracts/. If it is missed, the string literals survive and the finding is only half fixed.
  • Over-modelling. A full abstract-record status union, or a repository/unit-of-work layer to "properly" own the aggregate, would be a bigger diff than the defects justify — see Decisions.