Files
strangler-fig-demo/docs/architecture.md
T
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

6.9 KiB
Raw Blame History

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 13 touch nothing, which is what makes a failed adoption free; the preflight endpoint is literally this prefix, stopped early. Steps 46 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 13 · 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 46 · 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.