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>
179 lines
6.9 KiB
Markdown
179 lines
6.9 KiB
Markdown
# How the seams work
|
||
|
||
Diagrams only. The prose arguments live in the [README](../README.md) and the
|
||
[ADRs](adr/); 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.
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```mermaid
|
||
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.
|
||
|
||
```mermaid
|
||
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](adr/ADR-002-write-through-has-no-business-rules.md), 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`.*
|