docs: document form autosave + FE↔BE request lifecycle in ARCHITECTURE

New §2g explains field persistence (keystroke → model → 600ms snapshot
debounce; blur only marks touched, never saves) and §6a refreshes the stale
backend section with the real request lifecycle (NSwag client, httpClientFetch
seam, read/write traces) — both with relative links to the source files.
Add discovery pointers from the learning path (lesson 2.2 and the capstone).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-20 21:38:49 +02:00
co-authored by Claude Opus 4.8
parent 989a32acb4
commit 14210fa2b0
2 changed files with 84 additions and 1 deletions
@@ -333,6 +333,39 @@ stores the result. The route guard (`auth/auth.guard.ts`) just reads
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.
### 2g. Autosave — keystroke → model → debounced sync (not on blur)
A common assumption is "the form saves on blur." It doesn't. **Blur only marks a field
_touched_** so validation can show; it never writes the value or hits the network. In the
shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and
nothing more; the value is pushed on `(input)`, every keystroke
([`text-input.component.ts`](../../../src/app/shared/ui/text-input/text-input.component.ts):
`(input)` L29 → `onChange` L62, vs `(blur)="onTouched()"` L30).
The real flow has two stages, neither keyed on focus:
1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches
`{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the
Model is always current, on every keystroke, while editing.
([`herregistratie-wizard.component.ts`](../../../src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
L78 → [`herregistratie.machine.ts`](../../../src/app/herregistratie/domain/herregistratie.machine.ts)
L138-142, `setField`.)
2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine
`snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it
never dispatches, so it can't livelock the store). On the first save it lazily creates
the application and stamps `?aanvraag=<id>` into the URL, so a reload resumes the draft.
([`draft-sync.ts`](../../../src/app/registratie/application/draft-sync.ts):
`DEBOUNCE_MS` L34, `effect` L102-108, `flush` L88-98 → `ApplicationsAdapter.syncDraft`.)
The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the
edit optimistically in the reducer and records an undo step, then `scheduleSave()`
`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save`
([`brief.store.ts`](../../../src/app/brief/application/brief.store.ts) L157-166, L192-209).
So it _feels_ like save-on-blur only because you usually stop typing when you leave a
field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not
"lost focus."** Submit is a separate, explicit action (§2d).
---
## 3. "Parse, don't validate" — value objects
@@ -469,6 +502,50 @@ Practical notes, kept lazy:
single place the wire format meets our types.
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.
### 6a. The request lifecycle today
The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is
no longer hand-written DTOs — it's an **NSwag-generated typed client**
([`api-client.ts`](../../../src/app/shared/infrastructure/api-client.ts), regenerate with
`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a
`parse*` returning `Result` rather than `httpResource({ parse })`. End to end:
- **Proxy.** The app uses a relative base URL (`apiBaseUrl: ''`), so `/api` calls are
same-origin and `ng serve` proxies them to the backend on `:5000`.
([`environment.ts`](../../../src/environments/environment.ts),
[`proxy.conf.json`](../../../proxy.conf.json); `proxy.conf.docker.json` targets the
compose service.)
- **Client → HttpClient seam.** The NSwag client's `fetch` is routed through Angular's
`HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live:
`X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and
GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API
traffic. ([`api-client.provider.ts`](../../../src/app/shared/infrastructure/api-client.provider.ts):
`httpClientFetch` L47-82, `provideApiClient` L86-92; registered in
[`app.config.ts`](../../../src/app/app.config.ts) L37.)
- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=`
toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints).
**A read (dashboard):** `<app-async [data]="store.profile()">`
[`BigProfileStore`](../../../src/app/registratie/application/big-profile.store.ts) →
`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () =>
client.dashboardView() })`
([`dashboard-view.adapter.ts`](../../../src/app/registratie/infrastructure/dashboard-view.adapter.ts))
→ GET `/api/v1/dashboard-view``httpClientFetch` → proxy → backend → back through the
`parseDashboardView(json): Result` trust boundary → `RemoteData<DashboardView>` → rendered.
**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest`
([`submit-change-request.ts`](../../../src/app/registratie/application/submit-change-request.ts))
`runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807
ProblemDetails → string ([`submit.ts`](../../../src/app/shared/application/submit.ts)) →
[`change-request.adapter.ts`](../../../src/app/registratie/infrastructure/change-request.adapter.ts)
→ POST `/api/v1/change-requests``ok(referentie)` / `err(detail)` → dispatch
`SubmitConfirmed` / `SubmitFailed`.
**Backend.** A single minimal-API host computes business decisions server-side (BFF-lite),
returns ProblemDetails on rule rejection, and dedupes replays via `Idempotency-Key`
([`Program.cs`](../../../backend/src/BigRegister.Api/Program.cs): `/dashboard-view` L80,
`/change-requests` L120).
---
## 7. Mini-glossary
+7 -1
View File
@@ -153,6 +153,11 @@ the *outcome*. Reducer = "what the new state is"; command = "go do it, then say
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
steps are `visibleSteps(answers)`, not a stored field.
A field's value lands in the Model on **every keystroke** (not on blur — blur only marks
the field "touched"); a separate 600 ms debounce off the model snapshot autosaves the
draft to the backend, an effect that lives *outside* the reducer. See
`docs/reference/architecture/ARCHITECTURE.md` §2g.
**Do:** run `/form-machine` for a toy single field (say a "nickname" field with a max
length). Read the generated Model / Msg / reduce and its spec.
@@ -359,7 +364,8 @@ one is the **authority**?
**Go deeper:** `docs/reference/architecture/0001-bff-lite-decision-dtos.md`;
`docs/reference/fp-tea-atomic-design.md` Part 7 (the copy-paste recipes);
`docs/reference/architecture/ARCHITECTURE.md` §4. For how contexts scale to a second
`docs/reference/architecture/ARCHITECTURE.md` §4 (the recipe) and §6a (the full FE⇄BE
request lifecycle, read + write, with file links). For how contexts scale to a second
app and actor-based authorization, ADR-0002 (the advanced read).
---