Files
strangler-fig-demo/README.md
T
eho ec965976b0 docs: document the Angular portal in README
Notes the /portal path, that it's kept alongside new-frontend rather
than replacing it, and updates the container count and click-through
intro accordingly.
2026-07-31 09:19:04 +02:00

151 lines
8.2 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** (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 (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 10011012, owned ids `REG-2026-0001..0005`).
## What's built so far
**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 (§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
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.