Files
ehoandClaude Haiku 4.5 ddec15ccb2 docs(architecture): add diagrams and implementation playbook
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>
2026-08-01 09:15:39 +02:00

196 lines
9.6 KiB
Markdown
Raw Permalink 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 (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 10011012, 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.