Container count is now 10; adds checks that /portal/ and a direct deep link both serve the Angular app (200) through the proxy, proving both the routing wiring and the container's SPA fallback. Full smoke run verified green on a fresh stack.
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. 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)
Nine containers: two frontends (one placeholder, see below), three backends,
three databases, one proxy. new-frontend in this session 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 before a real Angular
portal replaces it in Session 2, not to be a good UI.
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
- Werkvoorraad —
GET /api/worklist(or the placeholder page at/): 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.