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>
This commit is contained in:
@@ -14,7 +14,8 @@ Then open **http://localhost:8080** (Session 1's placeholder UI) or
|
|||||||
**http://localhost:8080/portal** (Session 2's Angular portal) — both are
|
**http://localhost:8080/portal** (Session 2's Angular portal) — both are
|
||||||
reachable side by side through the same proxy, for comparison. That's the
|
reachable side by side through the same proxy, for comparison. That's the
|
||||||
only host port published — `legacy-backend` and `case-framework` are
|
only host port published — `legacy-backend` and `case-framework` are
|
||||||
deliberately unreachable from the host (see §4 below).
|
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
|
**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
|
total for Docker/Podman. First start takes a minute or two while SQL Server
|
||||||
@@ -32,6 +33,49 @@ seed data (legacy ids 1001–1012, owned ids `REG-2026-0001..0005`).
|
|||||||
|
|
||||||
## What's built so far
|
## 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,
|
**Session 1 — backend.** Ten containers: three frontends, three backends,
|
||||||
three databases, one proxy. `new-frontend` (reachable at `/`) is a
|
three databases, one proxy. `new-frontend` (reachable at `/`) is a
|
||||||
deliberately plain, unstyled HTML page (`new-frontend/index.html`) that
|
deliberately plain, unstyled HTML page (`new-frontend/index.html`) that
|
||||||
@@ -76,7 +120,8 @@ cd new && dotnet test tests/Architecture.Tests
|
|||||||
`legacy-db` is SQL Server 2022; `new-db` and `case-db` are PostgreSQL 16.
|
`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
|
This isn't decoration — a single shared engine would let an implementer
|
||||||
quietly join across schemas or share a `DbContext`, and the seam would
|
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),
|
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
|
force the take-ownership step ordering in `TakeOwnershipHandler` to be a real
|
||||||
constraint rather than a stylistic choice (no distributed transaction is
|
constraint rather than a stylistic choice (no distributed transaction is
|
||||||
available across them), and make the legacy type vocabulary
|
available across them), and make the legacy type vocabulary
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ Accepted.
|
|||||||
`case-framework` (seam D, a stand-in for a maintained vendor case-management
|
`case-framework` (seam D, a stand-in for a maintained vendor case-management
|
||||||
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
|
framework) refuses `POST /cases/{id}/closure-request` with **409 Conflict**
|
||||||
while any task on the case is still open. That rule belongs to the framework
|
while any task on the case is still open. That rule belongs to the framework
|
||||||
and is not ours to change — it is a conformist integration by design (§6).
|
and is not ours to change — it is a conformist integration by design
|
||||||
|
(seam D, `New.Infrastructure.CaseFramework/CaseFrameworkGateway.cs`).
|
||||||
|
|
||||||
The new domain's own rule is different: once an assessment (approve/reject) is
|
The new domain's own rule is different: once an assessment (approve/reject) is
|
||||||
recorded on a `RegistrationApplication`, that decision is legally in effect
|
recorded on a `RegistrationApplication`, that decision is legally in effect
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Accepted.
|
|||||||
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
|
Seam B lets a user edit a **legacy-owned** case's applicant details (name,
|
||||||
address, contact) from the new portal, without the new system taking
|
address, contact) from the new portal, without the new system taking
|
||||||
ownership of that case. The legacy system remains the authority on this data
|
ownership of that case. The legacy system remains the authority on this data
|
||||||
until ownership is explicitly taken (§7.5).
|
until ownership is explicitly taken ([ADR-003](ADR-003-ownership-is-taken-per-case.md)).
|
||||||
|
|
||||||
It is tempting, once a translation layer exists between the portal's request
|
It is tempting, once a translation layer exists between the portal's request
|
||||||
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
|
shape and legacy's `PUT /api/aanvragen/{id}/gegevens` shape, to also smuggle
|
||||||
@@ -28,13 +28,14 @@ derived values, no defaulting. It only:
|
|||||||
(logged as a warning, never dropped or guessed at).
|
(logged as a warning, never dropped or guessed at).
|
||||||
|
|
||||||
If a rule needs to be enforced on this data from the new portal, that is a
|
If a rule needs to be enforced on this data from the new portal, that is a
|
||||||
signal the capability should be taken into ownership instead (§7.5), not
|
signal the capability should be taken into ownership instead, not
|
||||||
patched into the translator.
|
patched into the translator.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
- The portal cannot offer a better validation experience than legacy already
|
- The portal cannot offer a better validation experience than legacy already
|
||||||
has for this seam — by design. The `Gevalideerd door het legacy systeem`
|
has for this seam — by design. The `Gevalideerd door het legacy systeem`
|
||||||
notice on the write-through form (§8.3) exists specifically so the user
|
notice on the write-through form (`portal-frontend/src/app/case-detail/edit-applicant-details/`)
|
||||||
|
exists specifically so the user
|
||||||
knows why: this is the honest version of a seamless UI, not a limitation to
|
knows why: this is the honest version of a seamless UI, not a limitation to
|
||||||
hide.
|
hide.
|
||||||
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
|
- Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ their keep.
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
Ship now with ownership taken **one legacy case at a time**, via
|
Ship now with ownership taken **one legacy case at a time**, via
|
||||||
`POST /api/worklist/legacy/{aanvraagId}/take-ownership` (§7.5), triggered by
|
`POST /api/worklist/legacy/{aanvraagId}/take-ownership`, triggered by
|
||||||
an explicit user action in the portal. This is the interim mechanism, not the
|
an explicit user action in the portal. This is the interim mechanism, not the
|
||||||
final one for every process.
|
final one for every process.
|
||||||
|
|
||||||
@@ -43,11 +43,12 @@ This is deliberately the right building block either way:
|
|||||||
- Until bulk tooling exists, full legacy retirement for a process happens
|
- Until bulk tooling exists, full legacy retirement for a process happens
|
||||||
case-by-case, which is slower than a scheduled cutover — accepted as the
|
case-by-case, which is slower than a scheduled cutover — accepted as the
|
||||||
cost of shipping the seam mechanics now rather than waiting.
|
cost of shipping the seam mechanics now rather than waiting.
|
||||||
- Reversal (§7.6) stays per-case and gated on `domain_writes_since` for the
|
- Reversal (`ReleaseOwnershipHandler`) stays per-case and gated on `domain_writes_since` for the
|
||||||
same reason a bulk reversal would be unsafe absent a sync
|
same reason a bulk reversal would be unsafe absent a sync
|
||||||
(`docs/sync-not-implemented.md`): undoing adoption after edits would
|
(`docs/sync-not-implemented.md`): undoing adoption after edits would
|
||||||
silently discard them.
|
silently discard them.
|
||||||
- This demo's non-goals (§3) exclude building the bulk migration tool itself
|
- This demo's non-goals (README, "Deliberate substitutions and omissions")
|
||||||
|
exclude building the bulk migration tool itself
|
||||||
— that's future work, not a rejected idea.
|
— that's future work, not a rejected idea.
|
||||||
|
|
||||||
## Future work
|
## Future work
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# 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`.*
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Applying this to a production system
|
||||||
|
|
||||||
|
The demo shows *that* the seams hold. This page is the transferable part: how
|
||||||
|
to pick a path for each capability, and the order that keeps a cutover cheap
|
||||||
|
to get wrong.
|
||||||
|
|
||||||
|
## Which write path for which capability
|
||||||
|
|
||||||
|
Ask this per capability, not per system. Most systems end up running all five
|
||||||
|
answers at once — that is the point of the pattern, not a sign of a messy
|
||||||
|
migration.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
S(["Pick one capability<br/>in the legacy system"]) --> Q1
|
||||||
|
|
||||||
|
Q1{"Does the new UI<br/>only need to read it?"}
|
||||||
|
Q1 -->|yes| A["<b>Read ACL</b><br/>translate at the boundary<br/>legacy stays authoritative"]
|
||||||
|
|
||||||
|
Q1 -->|no| Q2{"Is it owned by a system<br/>you don't control?"}
|
||||||
|
Q2 -->|yes| D["<b>Conformist</b><br/>surface its rules as-is<br/>don't fight or hide them"]
|
||||||
|
|
||||||
|
Q2 -->|no| Q3{"Have you rebuilt the<br/>domain rules yet?"}
|
||||||
|
Q3 -->|yes| E["<b>Take ownership</b><br/>new system becomes<br/>the authority"]
|
||||||
|
|
||||||
|
Q3 -->|"no — and it's<br/>a whole workflow"| C["<b>Redirect</b><br/>send the user back out<br/>to the legacy screen"]
|
||||||
|
Q3 -->|"no — but it's<br/>a simple edit"| B["<b>Write-through</b><br/>legacy still validates<br/>translator gets zero rules"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Read ACL and write-through are the cheap ones and where most capabilities
|
||||||
|
should sit for most of the migration. Take-ownership is the only path that
|
||||||
|
moves authority, so it is the only one that needs a rollback story.
|
||||||
|
|
||||||
|
## Ordering a cutover so failures stay cheap
|
||||||
|
|
||||||
|
The generalisable rule from `TakeOwnershipHandler`: **free checks first,
|
||||||
|
external systems before your local transaction, least-recoverable action
|
||||||
|
last** — and for whatever remains unrecoverable, write down how you would
|
||||||
|
*detect* it instead of pretending it rolls back.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph free["Costs nothing to fail"]
|
||||||
|
direction TB
|
||||||
|
S1["1 · cheap guard<br/>already migrated?"] --> S2["2 · read the source"] --> S3["3 · map it<br/>invariants run here"]
|
||||||
|
end
|
||||||
|
subgraph writes["Each failure leaves a trace"]
|
||||||
|
direction TB
|
||||||
|
S4["4 · external system"] --> S5["5 · your local tx<br/>atomic"] --> S6["6 · flip the old flag"]
|
||||||
|
end
|
||||||
|
free ==>|"a failure up to here<br/>means nothing happened"| writes
|
||||||
|
|
||||||
|
S4 -.->|"fails after?"| F1["orphaned external record<br/><i>find by external reference</i>"]
|
||||||
|
S6 -.->|"fails?"| F2["split-brain<br/><i>reconcile the two flags</i>"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Steps 1–3 doubling as a dry-run endpoint is what makes the rehearsal
|
||||||
|
trustworthy: it *is* the real cutover's own check code, so it cannot drift
|
||||||
|
away from what the real call will do.
|
||||||
|
|
||||||
|
## Seven rules worth stealing
|
||||||
|
|
||||||
|
| # | Rule | Why | Demonstrated by |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Migrate the smallest unit that already exists in the domain | Here it's one case. Per-unit cutover means a failure is one bad row, not a bad weekend | [ADR-003](adr/ADR-003-ownership-is-taken-per-case.md) |
|
||||||
|
| 2 | The translator carries **no** business rules | The urge to "just check the postcode here too" is the signal to migrate that capability instead | [ADR-002](adr/ADR-002-write-through-has-no-business-rules.md) |
|
||||||
|
| 3 | Give yourself a dry run built from the real thing | A rehearsal that shares code with the performance cannot go stale | `GET .../take-ownership/preflight` |
|
||||||
|
| 4 | Let the old system keep saying no | Every legacy error surfaces verbatim; none is invented or hidden | `LegacyDetailsWriteThroughTranslator` |
|
||||||
|
| 5 | Make the boundary fail the build | Exactly one type may know both sources exist; a reviewer will eventually miss that, a test won't | `tests/Architecture.Tests` (11 rules) |
|
||||||
|
| 6 | Write down what you deliberately did **not** build | An omission you named is a decision; an omission you didn't is a bug waiting | [sync-not-implemented.md](sync-not-implemented.md) |
|
||||||
|
| 7 | Reversibility expires — say so out loud | Release works until the new side holds authoritative writes; after that the honest answer is a `409`, not a silent discard | `ReleaseOwnershipHandler` |
|
||||||
|
|
||||||
|
## What this demo deliberately leaves for you
|
||||||
|
|
||||||
|
None of these are hard to add; all of them are decisions a real migration has
|
||||||
|
to make explicitly, so the demo declines to make them for you.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph shown["Proven here"]
|
||||||
|
direction LR
|
||||||
|
P1["4 seams"] ~~~ P2["3 write paths"] ~~~ P3["per-case cutover<br/>+ dry run"] ~~~ P4["reversal, while<br/>still reversible"]
|
||||||
|
end
|
||||||
|
subgraph yours["Yours to decide"]
|
||||||
|
direction LR
|
||||||
|
Y1["bulk / scheduled<br/>cutover"] ~~~ Y2["new → old sync"] ~~~ Y3["authn / authz"] ~~~ Y4["metrics, alerting,<br/>reconciliation jobs"]
|
||||||
|
end
|
||||||
|
shown --> yours
|
||||||
|
```
|
||||||
|
|
||||||
|
Two of these are already argued in writing rather than left blank:
|
||||||
|
[ADR-003](adr/ADR-003-ownership-is-taken-per-case.md) on why bulk migration is
|
||||||
|
a separate later capability, and
|
||||||
|
[sync-not-implemented.md](sync-not-implemented.md) on the two visible
|
||||||
|
consequences of having no sync.
|
||||||
@@ -21,6 +21,7 @@ consequences, both intentional:
|
|||||||
honest substitute for a sync that does not exist.
|
honest substitute for a sync that does not exist.
|
||||||
2. **Ownership release is blocked once edits exist.** `DELETE
|
2. **Ownership release is blocked once edits exist.** `DELETE
|
||||||
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
|
/api/worklist/owned/{id}/ownership` returns `409` once `domain_writes_since
|
||||||
> 0` (§7.6) — releasing would silently discard those edits, since there is
|
> 0` (`ReleaseOwnershipHandler`) — releasing would silently discard those
|
||||||
|
edits, since there is
|
||||||
no sync to have propagated them back to legacy first. The `409` is the cost
|
no sync to have propagated them back to legacy first. The `409` is the cost
|
||||||
of the missing sync made visible, rather than a data-loss bug made invisible.
|
of the missing sync made visible, rather than a data-loss bug made invisible.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace New.Api.Seeding;
|
|||||||
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
|
/// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and
|
||||||
/// README click-through can reference them directly). REG-2026-0002 is
|
/// README click-through can reference them directly). REG-2026-0002 is
|
||||||
/// seeded with an open case-framework task on purpose, so a later closure
|
/// seeded with an open case-framework task on purpose, so a later closure
|
||||||
/// request against it demonstrates the §6 conflict (409, decision stands).
|
/// request against it demonstrates the seam-D conflict (409, decision stands
|
||||||
|
/// - see docs/adr/ADR-001-decision-independent-of-closure.md).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class OwnedApplicationSeeder
|
internal static class OwnedApplicationSeeder
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ using Xunit;
|
|||||||
namespace Architecture.Tests;
|
namespace Architecture.Tests;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Encodes §10's architecture rules as build-failing assertions. A demo that
|
/// Encodes the design's architecture rules as build-failing assertions. A demo that
|
||||||
/// passes the smoke script but fails these has demonstrated nothing - the
|
/// passes the smoke script but fails these has demonstrated nothing - the
|
||||||
/// seam boundaries are the point, not an implementation detail.
|
/// seam boundaries are the point, not an implementation detail.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -70,7 +70,7 @@ public class ArchitectureTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
|
public void Rule5_No_Legacy_Or_CaseFramework_Connection_String_In_New_Config()
|
||||||
{
|
{
|
||||||
// Config-file concern, not code - see §10. Verified by inspection: the
|
// Config-file concern, not code. Verified by inspection: the
|
||||||
// only connection string anywhere under New.* is ConnectionStrings:New
|
// only connection string anywhere under New.* is ConnectionStrings:New
|
||||||
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
// (New.Infrastructure.Persistence.ServiceCollectionExtensions), and
|
||||||
// docker-compose.yml only ever injects ConnectionStrings__New into
|
// docker-compose.yml only ever injects ConnectionStrings__New into
|
||||||
|
|||||||
+15
-54
@@ -1,59 +1,20 @@
|
|||||||
# PortalFrontend
|
# portal-frontend
|
||||||
|
|
||||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.2.
|
Session 2's Angular portal — the real UI over the new backend, reachable at
|
||||||
|
**http://localhost:8080/portal** once the stack is up (`docker compose up -d`
|
||||||
|
from the repo root). It is served by its own nginx container behind the shared
|
||||||
|
proxy, *not* by `ng serve`.
|
||||||
|
|
||||||
## Development server
|
The whole app is driven off the API's `actions` and `seams` blocks: it renders
|
||||||
|
whatever write path each case advertises (`writeThrough`, `redirect`, `owned`,
|
||||||
|
`transition`, `query`) and never builds an endpoint URL from an id. That is
|
||||||
|
what makes the same screens work unchanged for a legacy-owned case and an
|
||||||
|
adopted one — see [`docs/architecture.md`](../docs/architecture.md).
|
||||||
|
|
||||||
To start a local development server, run:
|
```
|
||||||
|
npm test # unit tests (vitest, via ng test)
|
||||||
```bash
|
npm run build # production build
|
||||||
ng serve
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
This app is **zoneless**: state written from a `subscribe` callback must land
|
||||||
|
in a signal, or the DOM will not update.
|
||||||
## Code scaffolding
|
|
||||||
|
|
||||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate component component-name
|
|
||||||
```
|
|
||||||
|
|
||||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng generate --help
|
|
||||||
```
|
|
||||||
|
|
||||||
## Building
|
|
||||||
|
|
||||||
To build the project run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng build
|
|
||||||
```
|
|
||||||
|
|
||||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
|
||||||
|
|
||||||
## Running unit tests
|
|
||||||
|
|
||||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng test
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running end-to-end tests
|
|
||||||
|
|
||||||
For end-to-end (e2e) testing, run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ng e2e
|
|
||||||
```
|
|
||||||
|
|
||||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user