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>
6.9 KiB
How the seams work
Diagrams only. The prose arguments live in the README and the ADRs; each diagram below names the file it was traced from, so a reader can check it against the code rather than trust it.
The four seams
Who holds authority at each boundary. Seam C is the odd one out: it is not an
HTTP redirect, it is an ActionLink with mode: "redirect" in the JSON
actions block — the browser navigates when the user clicks it.
flowchart LR
portal["portal-frontend<br/>Angular"]
api["new-backend<br/>New.Api"]
legapi["legacy-backend<br/>Legacy.Api"]
legweb["legacy-frontend<br/>Beoordeling.cshtml"]
cf["case-framework<br/>vendor"]
portal --> api
api -- "A · read ACL<br/>GET /api/aanvragen<br/><b>legacy owns the data</b>" --> legapi
api -- "B · write-through<br/>PUT .../gegevens<br/><b>legacy owns the rules</b>" --> legapi
api -- "D · conformist<br/>POST /cases<br/><b>vendor owns the rules</b>" --> cf
portal -. "C · redirect — browser navigates<br/><b>legacy owns the workflow</b>" .-> legweb
Traced from New.Infrastructure.Legacy/LegacyCaseSource.cs,
LegacyDetailsWriteThroughTranslator.cs,
New.Api/Contracts/CaseDetailResponseFactory.cs,
New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs.
Reading a case: one id, two sources
ApplicationSourceResolver is the only type in the solution that references
both sources (Architecture.Tests rule 7). A legacy id keeps working after
adoption because this resolver — and only this resolver — checks the ownership
registry first.
The list endpoint deliberately does not go through it: it takes two
separate reader ports and merges in memory, dropping any legacy row whose
Migrated flag is set so adopted cases don't appear twice.
flowchart TB
subgraph byid["GET /api/worklist/legacy/{id} — via the resolver"]
r{"legacy_ownership<br/>has a row?"}
r -->|no| ra["LegacyCaseSource<br/>HTTP → legacy-backend"]
r -->|yes| rb["OwnedApplicationSource<br/>in-process → new-db"]
end
subgraph list["GET /api/worklist — bypasses the resolver"]
l1["ILegacyWorklistReader<br/>HTTP → legacy-backend"]
l2["IOwnedWorklistReader<br/>in-process → new-db"]
m["owned ++ legacy.Where(!Migrated)<br/>filter · sort · page in memory"]
l1 --> m
l2 --> m
end
Traced from New.Api/Resolution/ApplicationSourceResolver.cs:26 and
New.Api/Endpoints/WorklistEndpoints.cs:18.
The life of a case
This is the strategy in one picture. Every edge is a real endpoint with a real guard.
stateDiagram-v2
[*] --> Legacy
Legacy --> Owned: POST take-ownership → 201
Owned --> Legacy: DELETE ownership → 204
Owned --> OwnedDirty: owned edit or assessment
Legacy --> Legacy: preflight — read-only
Legacy --> Legacy: write-through edit — legacy validates
Legacy --> Legacy: take-ownership 422 — nothing written
OwnedDirty --> OwnedDirty: further owned writes
note right of Legacy
no legacy_ownership row
legacy.Migrated = false
legacy is the authority
end note
note right of Owned
legacy_ownership row exists
legacy.Migrated = true
domain_writes_since = 0
still reversible
end note
note right of OwnedDirty
domain_writes_since greater than 0
Release refused with 409: no sync
exists to push these edits back
to legacy first.
end note
Not drawn as a state, because it is a failure condition rather than a
lifecycle stage: split-brain — a row in legacy_ownership while legacy's
Migrated is still false, left behind when step 6 below fails. Detected by
reconciling the two, not prevented.
Traced from New.Application/Ownership/TakeOwnershipHandler.cs,
ReleaseOwnershipHandler.cs, and
New.Infrastructure.Persistence/Entities/LegacyOwnershipRow.cs.
Take ownership — the strangler step
The step order is load-bearing. Steps 1–3 touch nothing, which is what makes a failed adoption free; the preflight endpoint is literally this prefix, stopped early. Steps 4–6 are ordered so the least recoverable action happens last, and each remaining failure window is detectable rather than pretended away.
sequenceDiagram
participant P as Portal
participant A as new-backend<br/>TakeOwnershipHandler
participant N as new-db
participant L as legacy-backend
participant C as case-framework
Note over P,C: Steps 1–3 · CheckAsync() · nothing is written<br/>PreflightAsync() runs exactly this much, then stops
P->>A: POST .../take-ownership
A->>N: 1 · LookupOwnedIdAsync
N-->>A: row exists → 409 AlreadyOwned
A->>L: 2 · GET /api/aanvragen/{id}
L-->>A: legacy row (absent → 404)
A->>A: 3 · map to RegistrationApplication
Note over A: domain invariant fails → 422 naming it,<br/>and nothing has been written anywhere
Note over P,C: Steps 4–6 · writes begin
A->>C: 4 · POST /cases
C-->>A: caseId
Note over C: failure after this point leaves an orphaned<br/>framework case — no compensating delete exists,<br/>so find it by externalReference
A->>N: 5 · aggregate + legacy_ownership<br/>in ONE transaction
A->>L: 6 · PUT .../migratie-vlag true
Note over L: failure here is swallowed and logged → split-brain:<br/>owned locally, still writable in legacy.<br/>Reconcile legacy_ownership vs legacy.migrated
A-->>P: 201 { registrationApplicationId }
Traced from New.Application/Ownership/TakeOwnershipHandler.cs — the numbered
comments there are the source of truth for this diagram.
Write-through: whose rules run
The effort argument in one exchange. Three bad fields go in; three field errors come back, produced entirely by legacy's own validator. No validation logic crossed the seam.
sequenceDiagram
participant P as Portal
participant A as new-backend
participant T as LegacyDetailsWrite<br/>ThroughTranslator
participant L as legacy-backend<br/>GegevensValidator
P->>A: PUT .../legacy/1001/details<br/>blank surname · no house number · bad postcode
A->>T: ToLegacyRequest — reshape only
T->>L: PUT /api/aanvragen/1001/gegevens
L->>L: every rule runs HERE
L-->>T: 400 · NAAM_VERPLICHT<br/>HUISNR_VERPLICHT · POSTCODE_ONGELDIG
T->>T: ToPortalErrors — veld → field path
T-->>A: 3 field errors, messages verbatim
A-->>P: 400 · surname<br/>address.number · address.postalCode
Note over T: An unrecognized veld is logged and passed<br/>through, never dropped or guessed at
The translator carries no business rules at all — see ADR-002, which is also honest that this is enforced by code review, not by a test.
Traced from New.Infrastructure.Legacy/LegacyDetailsWriteThroughTranslator.cs
and legacy/src/Legacy.Api/Endpoints/GegevensValidator.cs.