# 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 ```mermaid flowchart TB browser(["Browser"]) browser -->|":8080 · the only published port"| proxy["proxy · nginx"] subgraph new_g["NEW — the replacement"] direction TB portal["portal-frontend
Angular"] placeholder["new-frontend
plain HTML"] newapi["new-backend
.NET"] newdb[("new-db
Postgres 16")] newapi --> newdb end subgraph legacy_g["LEGACY — being strangled"] direction TB legweb["legacy-frontend
Razor Pages"] legapi["legacy-backend
.NET"] legdb[("legacy-db
SQL Server 2022")] legweb --> legapi --> legdb end subgraph cf_g["VENDOR — stays put"] direction TB cf["case-framework
.NET"] cfdb[("case-db
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`](docs/architecture.md) for how each seam works, and [`docs/playbook.md`](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/`](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](docs/architecture.md#the-four-seams)), 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`](docs/adr/ADR-001-decision-independent-of-closure.md) — a register decision takes effect independently of case-framework closure. - [`ADR-002`](docs/adr/ADR-002-write-through-has-no-business-rules.md) — the write-through translator carries no business rules. - [`ADR-003`](docs/adr/ADR-003-ownership-is-taken-per-case.md) — ownership is taken per-case for now; bulk migration is a planned, separate capability. - [`sync-not-implemented.md`](docs/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. **Werkvoorraad** — `GET /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 payload** — `PUT /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-ownership` → `201`. 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-1005`** — `POST /api/worklist/legacy/1005/take-ownership` → `422`, 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-0002`** — `POST /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.