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>
196 lines
9.6 KiB
Markdown
196 lines
9.6 KiB
Markdown
# 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<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`](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.
|