Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f737e18c | ||
|
|
fa7e9c5cfb | ||
|
|
14210fa2b0 |
@@ -117,6 +117,12 @@ touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-own
|
|||||||
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
|
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
|
||||||
but the FE doesn't call them.
|
but the FE doesn't call them.
|
||||||
|
|
||||||
|
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
||||||
|
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
||||||
|
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
||||||
|
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
|
||||||
|
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
|
||||||
|
|
||||||
### 5. Testing
|
### 5. Testing
|
||||||
|
|
||||||
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
Vitest. Co-locate `*.spec.ts` next to the unit. **Domain and pure logic must have a
|
||||||
|
|||||||
@@ -1,29 +1,23 @@
|
|||||||
|
using BigRegister.Stamdata;
|
||||||
|
|
||||||
namespace BigRegister.Domain.Diplomas;
|
namespace BigRegister.Domain.Diplomas;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// SERVER-OWNED business rules for diplomas. This is the single place a policy
|
/// SERVER-OWNED business rules for diplomas: which profession a study program maps to,
|
||||||
/// changes: which profession a study program maps to, and which policy questions
|
/// and which policy questions (geldigheidsvragen) apply to a diploma. The frontend
|
||||||
/// (geldigheidsvragen) apply to a diploma. The frontend renders these; it never
|
/// renders these; it never derives them.
|
||||||
/// derives them.
|
///
|
||||||
|
/// The profession↔program *data* is business-editable stamdata in
|
||||||
|
/// <see cref="Professions"/> (config-as-code, ADR-0004); the *rules* below consume it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DiplomaRules
|
public static class DiplomaRules
|
||||||
{
|
{
|
||||||
// RULE: study program → BIG profession.
|
// RULE: study program → BIG profession (data lives in Stamdata.Professions).
|
||||||
private static readonly Dictionary<string, string> ProfessionByProgram = new(StringComparer.OrdinalIgnoreCase)
|
|
||||||
{
|
|
||||||
["geneeskunde"] = "Arts",
|
|
||||||
["verpleegkunde"] = "Verpleegkundige",
|
|
||||||
["fysiotherapie"] = "Fysiotherapeut",
|
|
||||||
["farmacie"] = "Apotheker",
|
|
||||||
["tandheelkunde"] = "Tandarts",
|
|
||||||
};
|
|
||||||
|
|
||||||
public static string ProfessionFor(Diploma d) =>
|
public static string ProfessionFor(Diploma d) =>
|
||||||
ProfessionByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
Professions.ByProgram.TryGetValue(d.Opleiding, out var beroep) ? beroep : "Onbekend";
|
||||||
|
|
||||||
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
/// <summary>Professions a user may declare for a manual (unlisted) diploma.</summary>
|
||||||
public static IReadOnlyList<string> ManualProfessions() =>
|
public static IReadOnlyList<string> ManualProfessions() => Professions.All();
|
||||||
ProfessionByProgram.Values.Distinct().ToList();
|
|
||||||
|
|
||||||
// --- Policy questions (geldigheidsvragen) ---
|
// --- Policy questions (geldigheidsvragen) ---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace BigRegister.Stamdata;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// BUSINESS-EDITABLE STAMDATA (config-as-code). Which BIG profession (beroep) each
|
||||||
|
/// study program (opleiding) maps to. This is the one table the business tunes when
|
||||||
|
/// a program starts or stops leading to a registered profession.
|
||||||
|
///
|
||||||
|
/// Change it by editing this file and opening a PR — NOT via a production database.
|
||||||
|
/// The C# compiler catches shape/type mistakes; <c>StamdataValidationTests</c> catches
|
||||||
|
/// the referential integrity it can't (e.g. a seeded diploma whose program has no
|
||||||
|
/// profession here). So a bad edit fails the build, never prod. See ADR-0004
|
||||||
|
/// (docs/reference/architecture/0004-stamdata-as-code.md).
|
||||||
|
///
|
||||||
|
/// This is DATA, not logic: the rules that consume it (which questions a diploma needs,
|
||||||
|
/// how a manual diploma is treated) stay in <c>DiplomaRules</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class Professions
|
||||||
|
{
|
||||||
|
public static readonly IReadOnlyDictionary<string, string> ByProgram =
|
||||||
|
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["geneeskunde"] = "Arts",
|
||||||
|
["verpleegkunde"] = "Verpleegkundige",
|
||||||
|
["fysiotherapie"] = "Fysiotherapeut",
|
||||||
|
["farmacie"] = "Apotheker",
|
||||||
|
["tandheelkunde"] = "Tandarts",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Distinct professions, in declaration order — the list a user may declare
|
||||||
|
/// for a manual (unlisted) diploma.</summary>
|
||||||
|
public static IReadOnlyList<string> All() => ByProgram.Values.Distinct().ToList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using BigRegister.Api.Data;
|
||||||
|
using BigRegister.Domain.Diplomas;
|
||||||
|
using BigRegister.Stamdata;
|
||||||
|
|
||||||
|
namespace BigRegister.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The compile-time gate for business-editable stamdata (ADR-0004). The C# compiler
|
||||||
|
/// already catches shape/type mistakes; these tests catch the referential integrity it
|
||||||
|
/// can't, so a bad config edit fails the build instead of reaching production.
|
||||||
|
/// </summary>
|
||||||
|
public class StamdataValidationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Every_seeded_diploma_program_maps_to_a_known_profession()
|
||||||
|
{
|
||||||
|
// The dangling-reference guard: a seed program with no entry in Professions would
|
||||||
|
// silently render "Onbekend" to the user. Fail the build instead.
|
||||||
|
foreach (var d in SeedData.Diplomas)
|
||||||
|
Assert.True(DiplomaRules.ProfessionFor(d) != "Onbekend",
|
||||||
|
$"Diploma program '{d.Opleiding}' has no profession in Stamdata.Professions.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Profession_map_has_no_blank_programs_or_professions()
|
||||||
|
{
|
||||||
|
Assert.All(Professions.ByProgram, kv =>
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(kv.Key), "A profession-map program key is blank.");
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(kv.Value), $"Program '{kv.Key}' maps to a blank profession.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Manual_professions_are_non_empty_and_distinct()
|
||||||
|
{
|
||||||
|
var professions = DiplomaRules.ManualProfessions();
|
||||||
|
Assert.NotEmpty(professions);
|
||||||
|
Assert.Equal(professions.Count, professions.Distinct().Count());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ condensed, cross-linked curriculum.
|
|||||||
| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). |
|
| [architecture/0001-bff-lite-decision-dtos.md](reference/architecture/0001-bff-lite-decision-dtos.md) | ADR — BFF-lite endpoints + decision DTOs (backend decides, FE renders). |
|
||||||
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
|
| [architecture/0002-user-groups-and-bounded-contexts.md](reference/architecture/0002-user-groups-and-bounded-contexts.md) | ADR — user groups as actors; identity vs authorization. |
|
||||||
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
|
| [architecture/0003-cibg-huisstijl.md](reference/architecture/0003-cibg-huisstijl.md) | ADR — adopt CIBG Huisstijl (vendored Bootstrap 5.2) + the token bridge. |
|
||||||
|
| [architecture/0004-stamdata-as-code.md](reference/architecture/0004-stamdata-as-code.md) | ADR — business-tunable reference data as typed, compile-time-validated config (not a production DB). |
|
||||||
| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. |
|
| [fp-tea-atomic-design.md](reference/fp-tea-atomic-design.md) | Long-form learning guide: FP + The Elm Architecture + atomic design. |
|
||||||
| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). |
|
| [wcag-checklist.md](reference/wcag-checklist.md) | Manual WCAG checks automation can't catch (tab order, focus traps, reflow). |
|
||||||
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
|
| [ui-ux-audit.md](reference/ui-ux-audit.md) | Early UI/UX audit against NL Design System (predates ADR-0003 — read in that light). |
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# ADR-0004 — Stamdata as code (config-as-code, not a production database)
|
||||||
|
|
||||||
|
Status: Accepted · Date: 2026-07-20
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The business needs to control certain inputs that change over time — the clearest example
|
||||||
|
being **which professions link to which diplomas** (`geneeskunde → Arts`, …), but also
|
||||||
|
tunable thresholds, policy-question text, document-category definitions, and some letter
|
||||||
|
copy. Two hard constraints:
|
||||||
|
|
||||||
|
1. **No managing this through a production database.** A live admin surface writing DB rows
|
||||||
|
means a bad value ships silently and is discovered in production.
|
||||||
|
2. **Issues must be caught at compile time.** A change should be typed, reviewed, and
|
||||||
|
versioned before it can affect anyone.
|
||||||
|
|
||||||
|
The codebase already leans this way but had never named it as a pattern, and one key table
|
||||||
|
was neither isolated nor validated:
|
||||||
|
|
||||||
|
- All reference data and thresholds are **compiled-in C# constants**, served through
|
||||||
|
screen-shaped BFF-lite endpoints; the frontend renders decisions and holds no reference
|
||||||
|
data (ADR-0001).
|
||||||
|
- User-facing UI copy is already **`$localize`** (`src/locale/*.xlf`) — git-tracked, and a
|
||||||
|
second locale is a translation file, not a code change. That is already the compile-time
|
||||||
|
model for text.
|
||||||
|
- The profession↔diploma map lived as a *private* `Dictionary` inside `DiplomaRules`, mixed
|
||||||
|
in with the rules that consume it, with **no cross-reference check**: a diploma whose
|
||||||
|
program wasn't in the map silently rendered `"Onbekend"`.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Treat business-tunable reference data as **stamdata-as-code**: typed, checked-in
|
||||||
|
configuration, changed through the normal git → PR → build → deploy pipeline. Never a
|
||||||
|
production database, never runtime-editable.
|
||||||
|
|
||||||
|
1. **One home, typed.** Business-editable reference data lives in the
|
||||||
|
`BigRegister.Stamdata` namespace (`backend/src/BigRegister.Api/Stamdata/`), one file per
|
||||||
|
concern, as plain typed C# data (records / dictionaries). Separate the **data** (what the
|
||||||
|
business tunes) from the **rules** (dev-owned logic that consumes it): the profession
|
||||||
|
*table* is `Stamdata.Professions`; the *rule* "an English diploma needs a B2 question"
|
||||||
|
stays in `DiplomaRules`.
|
||||||
|
2. **Served unchanged.** The existing BFF-lite endpoints keep serving this data
|
||||||
|
(`/duo/diplomas`, `/intake/policy`, `/uploads/categories`, …). No frontend change — the
|
||||||
|
FE still renders decisions.
|
||||||
|
3. **Two gates.** The **C# compiler** catches shape and type mistakes. A build-time
|
||||||
|
**`StamdataValidationTests`** catches the referential integrity the compiler can't —
|
||||||
|
every seeded diploma program resolves to a real profession, no blank keys/values,
|
||||||
|
thresholds in range. CI runs it, so a bad edit fails the build and never merges.
|
||||||
|
4. **Business control = config-as-code (GitOps).** The business owns the content of these
|
||||||
|
files; a change is a reviewed edit, not a live DB write. A future low-code editor could
|
||||||
|
commit a PR on their behalf without changing this model (the compile-time gate stays).
|
||||||
|
|
||||||
|
### Where each kind of business-controllable thing lives
|
||||||
|
|
||||||
|
| Kind | Home | Gate |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Reference tables + tunable numbers (professions↔diplomas, thresholds, policy questions, document categories) | `Stamdata/` typed C# | compiler + `StamdataValidationTests` |
|
||||||
|
| User-facing UI copy | `$localize` → `src/locale/*.xlf` | build (`i18nMissingTranslation: error`) |
|
||||||
|
| Letter / brief passage content | config-as-code in the backend (seed content), **not** the DB | compiler + endpoint tests |
|
||||||
|
|
||||||
|
### The deliberate exception: org-templates
|
||||||
|
|
||||||
|
Per-organization letterhead (return address, footer, signature, margins) **is**
|
||||||
|
runtime-editable in SQLite, via the org-template admin editor (WP-23/26). That is
|
||||||
|
intentional and does not contradict this ADR: it is *operational configuration* owned by an
|
||||||
|
admin persona, versioned with publish/rollback inside the app, and specific to one
|
||||||
|
sub-organization's identity — not the shared business rules a wrong value would break for
|
||||||
|
everyone. Stamdata (the rules and reference tables the whole register runs on) stays code.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **+** Every change is typed, reviewed, versioned, and rollback-able through git; zero
|
||||||
|
production-DB risk; a dangling reference fails the build with a clear message instead of
|
||||||
|
reaching users.
|
||||||
|
- **−** A change needs the PR pipeline — not instant, and a non-developer may need dev
|
||||||
|
assistance to edit C# (mitigated later by a low-code editor that emits a PR, or by a
|
||||||
|
data-file format if hand-editing ergonomics ever outweigh maximal compile-time safety).
|
||||||
|
- **Pilot shipped with this ADR:** the profession↔diploma map extracted to
|
||||||
|
`Stamdata/Professions.cs`, `DiplomaRules` refactored to consume it (behaviour unchanged),
|
||||||
|
and `StamdataValidationTests` added. Policy-question text and document-category
|
||||||
|
definitions follow the same pattern as obvious next steps; not moved yet.
|
||||||
@@ -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.
|
`store.isAuthenticated()` and redirects to `/login` if you're not signed in.
|
||||||
Protected routes list `canActivate: [authGuard]` in `app.routes.ts`.
|
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
|
## 3. "Parse, don't validate" — value objects
|
||||||
@@ -469,6 +502,50 @@ Practical notes, kept lazy:
|
|||||||
single place the wire format meets our types.
|
single place the wire format meets our types.
|
||||||
- Nothing else moves: `<app-async>`, the stores, and every page keep working unchanged.
|
- 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. Mini-glossary
|
||||||
|
|||||||
@@ -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
|
happened." And **derive, don't store** anything you can compute — e.g. a wizard's visible
|
||||||
steps are `visibleSteps(answers)`, not a stored field.
|
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
|
**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.
|
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`;
|
**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/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).
|
app and actor-based authorization, ADR-0002 (the advanced read).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user