Enables dry-run checking before committing to case adoption. The preflight shares the same side-effect-free checks (steps 1–3) as the real take-ownership handler, so it cannot drift from what will actually succeed. Returns the same status codes and error shapes as the real endpoint (200 with wouldSucceed:true, or 409/404/422 if it would fail). Portal renders a "Vooraf controleren" button for legacy cases, surfaced through the existing actions block pattern. Confirmed in smoke.sh with two cases: one where preflight predicts success (and writes nothing), one where it predicts a named invariant failure (matching what the real call reproduces). Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Strangler seam demo: Behandel portaal
A reference demo, not a product. It makes four integration seams and three write paths between a legacy system and its replacement runnable, so a migration strategy can be watched instead of slide-decked.
Run it
docker compose up -d --build
Then open http://localhost:8080 (Session 1's placeholder UI) or
http://localhost:8080/portal (Session 2's Angular portal) — both are
reachable side by side through the same proxy, for comparison. That's the
only host port published — legacy-backend and case-framework are
deliberately unreachable from the host (see §4 below).
Memory: SQL Server (legacy-db) needs roughly 2GB of RAM; budget ~6GB
total for Docker/Podman. First start takes a minute or two while SQL Server
initialises (the healthcheck has a 60s start_period) — the app containers
wait on it before running their own migrations and seed data.
Verify everything end to end:
./scripts/smoke.sh
Run this against a freshly started stack — it depends on the untouched
seed data (legacy ids 1001–1012, owned ids REG-2026-0001..0005).
What's built so far
Session 1 — backend. Ten containers: three frontends, three backends,
three databases, one proxy. new-frontend (reachable at /) is a
deliberately plain, unstyled HTML page (new-frontend/index.html) that
exercises the same API a real UI would — it exists to prove the backend, not
to be a good UI.
Session 2 — Angular portal. portal-frontend (reachable at /portal) is
a real Angular application over the same API, kept alongside new-frontend
rather than replacing it, so the two can be compared side by side. It covers
full functional parity with the placeholder: the worklist, case detail, and
all four actions (edit applicant details, record assessment, take/release
ownership) — driven entirely off the API's own actions/seams blocks in
each response, never a hardcoded URL. See
portal-frontend/ for the app itself.
The seams and write paths
| Seam / write path | Direction | Implementation | Proven by |
|---|---|---|---|
| A — Read ACL | new backend → legacy API | new/src/New.Infrastructure.Legacy/LegacyCaseSource.cs, LegacyWorklistReader.cs |
GET /api/worklist returns 17 merged items |
| B — Write-through ACL | new backend → legacy API | new/src/New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs |
valid edit persists to legacy-db; a 3-field-invalid payload returns 3 mapped field errors |
| C — Redirect | new frontend → legacy frontend | CaseDetailResponseFactory.BuildLegacyActions (new/src/New.Api/Contracts/CaseDetailResponseFactory.cs) |
a legacy case's recordAssessment action has mode: "redirect" |
| D — Conformist | new backend → case framework | new/src/New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs |
case-framework's 409-on-open-task rule surfaces as closurePending on assessment |
| Redirect write path | legacy enforces | legacy/src/Legacy.Web/Pages/Beoordeling.cshtml |
outbound button, not a form |
| Write-through write path | legacy enforces | PUT /api/worklist/legacy/{id}/details |
Gevalideerd door het legacy systeem-equivalent: every legacy error surfaces, none invented |
| Owned write path | new domain enforces | New.Application.Assessments.RecordOwnedAssessmentHandler, UpdateOwnedApplicantDetailsHandler |
direct invalid payload to the assessment endpoint returns 422 |
| Take ownership | the strangler step | New.Application.Ownership.TakeOwnershipHandler |
POST /api/worklist/legacy/{id}/take-ownership flips the resolver, seam inspector, and legacy's MIGRATED flag together |
| Release ownership | reversal | New.Application.Ownership.ReleaseOwnershipHandler |
204 with no edits, 409 once domain_writes_since > 0 |
The single component that knows both sources exist is
New.Api.Resolution.ApplicationSourceResolver — enforced by
Architecture.Tests (rule 7), along with 10 other rules (project-reference
direction, no bare Status in the domain, no SQL Server package reference
anywhere under new/, ...). Run them with:
cd new && dotnet test tests/Architecture.Tests
Why two database engines
legacy-db is SQL Server 2022; new-db and case-db are PostgreSQL 16.
This isn't decoration — a single shared engine would let an implementer
quietly join across schemas or share a DbContext, and the seam would
evaporate. Two engines force the read ACL to be a real HTTP call (§7.2),
force the take-ownership step ordering in TakeOwnershipHandler to be a real
constraint rather than a stylistic choice (no distributed transaction is
available across them), and make the legacy type vocabulary
(CHAR/BIT/DATETIME2, space-padded BSNs, local-time timestamps) into real
work for LegacyAanvraagMapper instead of a copy-paste.
Deliberate substitutions and omissions
- Legacy.Web (Razor Pages) stands in for WinUI. WinUI can't be containerised; a server-rendered, table-heavy, deliberately dated UI reads as "legacy" just as effectively.
- No auth. Out of scope for the whole demo — see
docs/adr/for what is in scope. - No data sync between the two databases — documented, not built. See
docs/sync-not-implemented.mdfor its two visible consequences (adopted legacy rows show as stale-and-locked, and ownership release is blocked once edits exist). - No bulk migration tooling. Ownership is taken one legacy case at a
time, as an interim mechanism — see
docs/adr/ADR-003-ownership-is-taken-per-case.mdfor why, and for the intended path to a future bulk cutover for processes that want one.
Architecture Decision Records
ADR-001— a register decision takes effect independently of case-framework closure.ADR-002— the write-through translator carries no business rules.ADR-003— ownership is taken per-case for now; bulk migration is a planned, separate capability.sync-not-implemented.md.
10-minute click-through
Every step below works identically through the raw API, the Session 1
placeholder at /, or the Session 2 Angular portal at /portal — they're
three windows onto the same backend.
- Werkvoorraad —
GET /api/worklist(or either frontend's root page): 17 cases from two databases in one list. Filter?origin=Legacy/?origin=Ownedto see which is which. A-1001(GET /api/worklist/legacy/1001) — all three write paths visible in oneactionsblock; theseamsblock names where each section's data comes from.Gegevens wijzigenwith a bad payload —PUT /api/worklist/legacy/1001/detailswith a blank surname, missing house number, and malformed postcode returns three field-level errors, one per input.Beoordelingon a legacy case —actions.recordAssessment.mode == "redirect"; following it lands on/legacy/aanvraag/1001/beoordeling, outside the new portal.A-1002— take ownership.POST /api/worklist/legacy/1002/take-ownership→201. Re-fetch the same case by its new id: theseamsblock now readsownedthroughout, the redirect and write-through actions are gone, replaced by owned-mode actions. This is the argument the whole demo is making — same screen, same two actions, only the authority changed./legacy— row 1002 now renders greyed out with abeheerd in nieuw portaallink back into the new portal.A-1005—POST /api/worklist/legacy/1005/take-ownership→422, namingBsn.ElevenProof. Three more distinct adoption failures exist at1003(contact),1006(motivation),1007(partial address) — one failure looks like a bug, four look like a policy.REG-2026-0002—POST /api/worklist/owned/00000000-0000-0000-0000-000000000002/assessmentsucceeds and reportsclosurePending: true; the case-framework's own closure-request is genuinely conflicted (an open task), and the decision stands regardless.
Step 5 is the argument; everything before it is setup, everything after is evidence that the boundaries hold.