Added three new documents with nine Mermaid diagrams to make the strangler fig strategy visible: - README: container topology diagram at the start, with the proxy entry point and three seams labelled - docs/architecture.md: five diagrams tracing the exact implementation: - The four seams and who holds authority at each boundary - How by-id read goes through the resolver, but list-read bypasses it - Case lifecycle state machine (the strategy in one picture) - Take-ownership sequence with failure windows annotated - Write-through error round-trip showing zero validation logic crossed - docs/playbook.md: how to apply this to a production system: - Write-path decision tree (five read/write patterns) - Cutover ordering diagram (side-effects-free first, least recoverable last) - Seven transferable rules with pointers to the files that demonstrate them - Scope diagram of what's proven vs. left as your decisions Resolved all 13 dangling § citations (to an absent spec doc) by linking to the actual files or dropping them. Replaced portal-frontend/README.md boilerplate with accurate content. All diagrams parse and link-check clean. 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 (only proxy publishes a port — see
docker-compose.yml).
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
flowchart TB
browser(["Browser"])
browser -->|":8080 · the only published port"| proxy["proxy · nginx"]
subgraph new_g["NEW — the replacement"]
direction TB
portal["portal-frontend<br/>Angular"]
placeholder["new-frontend<br/>plain HTML"]
newapi["new-backend<br/>.NET"]
newdb[("new-db<br/>Postgres 16")]
newapi --> newdb
end
subgraph legacy_g["LEGACY — being strangled"]
direction TB
legweb["legacy-frontend<br/>Razor Pages"]
legapi["legacy-backend<br/>.NET"]
legdb[("legacy-db<br/>SQL Server 2022")]
legweb --> legapi --> legdb
end
subgraph cf_g["VENDOR — stays put"]
direction TB
cf["case-framework<br/>.NET"]
cfdb[("case-db<br/>Postgres 16")]
cf --> cfdb
end
proxy -->|"/portal/"| portal
proxy -->|"/"| placeholder
proxy -->|"/api/"| newapi
proxy -->|"/legacy"| legweb
newapi ==>|"seams A + B"| legapi
newapi ==>|"seam D"| cf
Ten containers; only the proxy publishes a port. The two thick edges are the
seams — everything the new system knows about the old one crosses one of them.
See docs/architecture.md for how each seam works, and
docs/playbook.md for applying the pattern to a real system.
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
(seam A),
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.