Files
strangler-fig-demo/README.md
T
ehoandClaude Haiku 4.5 ddec15ccb2 docs(architecture): add diagrams and implementation playbook
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>
2026-08-01 09:15:39 +02:00

9.6 KiB
Raw Blame History

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 10011012, 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.md for 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.md for 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.

  1. WerkvoorraadGET /api/worklist (or either frontend's root page): 17 cases from two databases in one list. Filter ?origin=Legacy / ?origin=Owned to see which is which.
  2. A-1001 (GET /api/worklist/legacy/1001) — all three write paths visible in one actions block; the seams block names where each section's data comes from.
  3. Gegevens wijzigen with a bad payloadPUT /api/worklist/legacy/1001/details with a blank surname, missing house number, and malformed postcode returns three field-level errors, one per input.
  4. Beoordeling on a legacy case — actions.recordAssessment.mode == "redirect"; following it lands on /legacy/aanvraag/1001/beoordeling, outside the new portal.
  5. A-1002 — take ownership. POST /api/worklist/legacy/1002/take-ownership201. Re-fetch the same case by its new id: the seams block now reads owned throughout, 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.
  6. /legacy — row 1002 now renders greyed out with a beheerd in nieuw portaal link back into the new portal.
  7. A-1005POST /api/worklist/legacy/1005/take-ownership422, naming Bsn.ElevenProof. Three more distinct adoption failures exist at 1003 (contact), 1006 (motivation), 1007 (partial address) — one failure looks like a bug, four look like a policy.
  8. REG-2026-0002POST /api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment succeeds and reports closurePending: 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.