# 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.