From ddec15ccb25be0575f618878030df82c1660a595 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 1 Aug 2026 09:15:39 +0200 Subject: [PATCH] docs(architecture): add diagrams and implementation playbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 49 ++++- ...ADR-001-decision-independent-of-closure.md | 3 +- ...002-write-through-has-no-business-rules.md | 7 +- .../ADR-003-ownership-is-taken-per-case.md | 7 +- docs/architecture.md | 178 ++++++++++++++++++ docs/playbook.md | 95 ++++++++++ docs/sync-not-implemented.md | 3 +- .../New.Api/Seeding/OwnedApplicationSeeder.cs | 3 +- .../Architecture.Tests/ArchitectureTests.cs | 4 +- portal-frontend/README.md | 69 ++----- 10 files changed, 351 insertions(+), 67 deletions(-) create mode 100644 docs/architecture.md create mode 100644 docs/playbook.md diff --git a/README.md b/README.md index f89e48e..b89622c 100644 --- a/README.md +++ b/README.md @@ -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 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 (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 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 +```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
Angular"] + placeholder["new-frontend
plain HTML"] + newapi["new-backend
.NET"] + newdb[("new-db
Postgres 16")] + newapi --> newdb + end + + subgraph legacy_g["LEGACY — being strangled"] + direction TB + legweb["legacy-frontend
Razor Pages"] + legapi["legacy-backend
.NET"] + legdb[("legacy-db
SQL Server 2022")] + legweb --> legapi --> legdb + end + + subgraph cf_g["VENDOR — stays put"] + direction TB + cf["case-framework
.NET"] + cfdb[("case-db
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 @@ -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. 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 (§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 constraint rather than a stylistic choice (no distributed transaction is available across them), and make the legacy type vocabulary diff --git a/docs/adr/ADR-001-decision-independent-of-closure.md b/docs/adr/ADR-001-decision-independent-of-closure.md index 648bbfe..48178df 100644 --- a/docs/adr/ADR-001-decision-independent-of-closure.md +++ b/docs/adr/ADR-001-decision-independent-of-closure.md @@ -7,7 +7,8 @@ Accepted. `case-framework` (seam D, a stand-in for a maintained vendor case-management 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 -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 recorded on a `RegistrationApplication`, that decision is legally in effect diff --git a/docs/adr/ADR-002-write-through-has-no-business-rules.md b/docs/adr/ADR-002-write-through-has-no-business-rules.md index 4f8f34d..c59f350 100644 --- a/docs/adr/ADR-002-write-through-has-no-business-rules.md +++ b/docs/adr/ADR-002-write-through-has-no-business-rules.md @@ -7,7 +7,7 @@ Accepted. 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 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 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). 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. ## Consequences - The portal cannot offer a better validation experience than legacy already 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 hide. - Rule 11 in Architecture.Tests (no `New.Api` type both constructs a legacy diff --git a/docs/adr/ADR-003-ownership-is-taken-per-case.md b/docs/adr/ADR-003-ownership-is-taken-per-case.md index aeb5227..ec6bac1 100644 --- a/docs/adr/ADR-003-ownership-is-taken-per-case.md +++ b/docs/adr/ADR-003-ownership-is-taken-per-case.md @@ -22,7 +22,7 @@ their keep. ## Decision 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 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 case-by-case, which is slower than a scheduled cutover — accepted as the 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 (`docs/sync-not-implemented.md`): undoing adoption after edits would 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. ## Future work diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..2076166 --- /dev/null +++ b/docs/architecture.md @@ -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
Angular"] + api["new-backend
New.Api"] + legapi["legacy-backend
Legacy.Api"] + legweb["legacy-frontend
Beoordeling.cshtml"] + cf["case-framework
vendor"] + + portal --> api + api -- "A · read ACL
GET /api/aanvragen
legacy owns the data" --> legapi + api -- "B · write-through
PUT .../gegevens
legacy owns the rules" --> legapi + api -- "D · conformist
POST /cases
vendor owns the rules" --> cf + portal -. "C · redirect — browser navigates
legacy owns the workflow" .-> 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
has a row?"} + r -->|no| ra["LegacyCaseSource
HTTP → legacy-backend"] + r -->|yes| rb["OwnedApplicationSource
in-process → new-db"] + end + + subgraph list["GET /api/worklist — bypasses the resolver"] + l1["ILegacyWorklistReader
HTTP → legacy-backend"] + l2["IOwnedWorklistReader
in-process → new-db"] + m["owned ++ legacy.Where(!Migrated)
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
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
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,
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
framework case — no compensating delete exists,
so find it by externalReference + A->>N: 5 · aggregate + legacy_ownership
in ONE transaction + A->>L: 6 · PUT .../migratie-vlag true + Note over L: failure here is swallowed and logged → split-brain:
owned locally, still writable in legacy.
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
ThroughTranslator + participant L as legacy-backend
GegevensValidator + + P->>A: PUT .../legacy/1001/details
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
HUISNR_VERPLICHT · POSTCODE_ONGELDIG + T->>T: ToPortalErrors — veld → field path + T-->>A: 3 field errors, messages verbatim + A-->>P: 400 · surname
address.number · address.postalCode + Note over T: An unrecognized veld is logged and passed
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`.* diff --git a/docs/playbook.md b/docs/playbook.md new file mode 100644 index 0000000..5b9211d --- /dev/null +++ b/docs/playbook.md @@ -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
in the legacy system"]) --> Q1 + + Q1{"Does the new UI
only need to read it?"} + Q1 -->|yes| A["Read ACL
translate at the boundary
legacy stays authoritative"] + + Q1 -->|no| Q2{"Is it owned by a system
you don't control?"} + Q2 -->|yes| D["Conformist
surface its rules as-is
don't fight or hide them"] + + Q2 -->|no| Q3{"Have you rebuilt the
domain rules yet?"} + Q3 -->|yes| E["Take ownership
new system becomes
the authority"] + + Q3 -->|"no — and it's
a whole workflow"| C["Redirect
send the user back out
to the legacy screen"] + Q3 -->|"no — but it's
a simple edit"| B["Write-through
legacy still validates
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
already migrated?"] --> S2["2 · read the source"] --> S3["3 · map it
invariants run here"] + end + subgraph writes["Each failure leaves a trace"] + direction TB + S4["4 · external system"] --> S5["5 · your local tx
atomic"] --> S6["6 · flip the old flag"] + end + free ==>|"a failure up to here
means nothing happened"| writes + + S4 -.->|"fails after?"| F1["orphaned external record
find by external reference"] + S6 -.->|"fails?"| F2["split-brain
reconcile the two flags"] +``` + +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
+ dry run"] ~~~ P4["reversal, while
still reversible"] + end + subgraph yours["Yours to decide"] + direction LR + Y1["bulk / scheduled
cutover"] ~~~ Y2["new → old sync"] ~~~ Y3["authn / authz"] ~~~ Y4["metrics, alerting,
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. diff --git a/docs/sync-not-implemented.md b/docs/sync-not-implemented.md index 898d95d..444ba4d 100644 --- a/docs/sync-not-implemented.md +++ b/docs/sync-not-implemented.md @@ -21,6 +21,7 @@ consequences, both intentional: honest substitute for a sync that does not exist. 2. **Ownership release is blocked once edits exist.** `DELETE /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 of the missing sync made visible, rather than a data-loss bug made invisible. diff --git a/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs b/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs index 71b249f..5fa86a1 100644 --- a/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs +++ b/new/src/New.Api/Seeding/OwnedApplicationSeeder.cs @@ -11,7 +11,8 @@ namespace New.Api.Seeding; /// REG-2026-0001..0005 (fixed, deterministic ids so the smoke script and /// README click-through can reference them directly). REG-2026-0002 is /// 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). /// internal static class OwnedApplicationSeeder { diff --git a/new/tests/Architecture.Tests/ArchitectureTests.cs b/new/tests/Architecture.Tests/ArchitectureTests.cs index e765cd5..786f773 100644 --- a/new/tests/Architecture.Tests/ArchitectureTests.cs +++ b/new/tests/Architecture.Tests/ArchitectureTests.cs @@ -9,7 +9,7 @@ using Xunit; namespace Architecture.Tests; /// -/// 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 /// seam boundaries are the point, not an implementation detail. /// @@ -70,7 +70,7 @@ public class ArchitectureTests [Fact] 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 // (New.Infrastructure.Persistence.ServiceCollectionExtensions), and // docker-compose.yml only ever injects ConnectionStrings__New into diff --git a/portal-frontend/README.md b/portal-frontend/README.md index 1057e55..c7799fb 100644 --- a/portal-frontend/README.md +++ b/portal-frontend/README.md @@ -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: - -```bash -ng serve +``` +npm test # unit tests (vitest, via ng test) +npm run build # production build ``` -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. - -## 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. +This app is **zoneless**: state written from a `subscribe` callback must land +in a signal, or the DOM will not update.