Files
strangler-fig-demo/README.md
T
ehoandClaude Sonnet 5 a6a1abbe9c feat: implement strangler-fig-demo Session 1 (backend + smoke script)
Builds the four-seam, three-write-path reference demo backend: case-framework
(seam D stand-in), legacy-backend/frontend (SQL Server, seams A/B/C targets),
and new-backend (Domain/Application/Infrastructure.*/Api implementing the
source resolver, take/release-ownership, write-through translator, and owned
assessment flow), wired together via docker-compose with a plain placeholder
frontend standing in for the Angular portal until Session 2.

All 11 Architecture.Tests pass and scripts/smoke.sh passes end-to-end against
a fresh `docker compose up`, covering acceptance criteria 1-3 and 7-22.

Fixes two real domain bugs found only once the stack ran for real: the BSN
eleven-proof checksum trivially passes all-zero digits, and the adoption
mapper silently treated a partial legacy address as absent instead of failing
loudly. Also fixes several environment-specific integration issues (rootless
Podman/SELinux bind-mount permissions, a buildah NuGet layer-caching bug,
SqlClient's invariant-globalization incompatibility, and an nginx path-prefix
mismatch for the legacy frontend).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 07:57:26 +02:00

136 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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**. That's the only host port published —
`legacy-backend` and `case-framework` are deliberately unreachable from the
host (see §4 below).
**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 (Session 1 — backend)
Nine containers: two frontends (one placeholder, see below), three backends,
three databases, one proxy. `new-frontend` in this session 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 before a real Angular
portal replaces it in Session 2, not to be a good UI.
## 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 (§7.2),
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
1. **Werkvoorraad** — `GET /api/worklist` (or the placeholder page at `/`):
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.