The architect approved the four ADR-fix tickets. All four change what the architecture documents claim. No code changes. ADR-0001, ADR-C-001: the worked example claimed the POC has no real backend. It rewrites against `backend/src/BigRegister.Api`. Every path it named is repointed. The out-of-scope list drops two discharged bullets: 33 `parse*` boundaries exist, and `npm run gen:api` is real. ADR-0001, ADR-C-003: a new section states that the generated client is the wire contract. A hand-written `contracts/*.dto.ts` is the exception for two cases only. The four survivors stay, because NSwag emits every property as optional and flattens `RegistrationStatusDto` into five optional strings. The `parse*` trust boundary stays mandatory, because a generated type is a compile-time claim about the wire and not a runtime guarantee. ADR-0003, ADR-C-007: four paths moved in WP-67 and are repointed. Point 4 kept the principle and changed its example to `skeleton` and `spinner`. Two of its claims were false and the amendment says so: `app-alert` wraps the vendored `.feedback` classes, and `site-header` composes the vendored `.titlebar`. ADR-0004, ADR-C-009: the exception section states a four-part test instead of one named exception. `OrgTemplateStore` and `FeatureFlagStore` both pass it. RB-07 gated this ticket, because clause 4 needs an audited allow path. RB-07 landed that, so the ADR does not ratify a control that the code lacks. Three tickets need a matching CLAUDE.md correction in the same diff. CLAUDE.md section 2 loses the false `alert` example. Section 4 gets the generated-client rule and the four-part test. Two findings were wrong. ADR-C-001 asked to keep an out-of-scope bullet that reads "SessionStore is in-memory". The session persists to `localStorage` now, so the bullet covers multi-tab sync only. ADR-C-007 flagged one half of point 4 and missed that the other half is equally false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
188 lines
10 KiB
Markdown
188 lines
10 KiB
Markdown
# ADR 0001 — Frontend⇄backend: BFF-lite endpoints + decision DTOs
|
||
|
||
Status: Accepted · Date: 2026-06-26
|
||
|
||
## Problem
|
||
|
||
The frontend makes many separate calls and aggregates them itself, and business
|
||
rules are hardwired in the client. Two concrete symptoms:
|
||
|
||
- The dashboard stitched **three** independent `httpResource`s (BIG-register
|
||
registration, BRP person, notes) together client-side. Each could be
|
||
loading/erroring independently → inconsistent snapshots ("state out of sync").
|
||
- Policy was duplicated on the client: the scholing threshold (`1000`) and the
|
||
herregistratie eligibility window (`12` months) lived in frontend code. If the
|
||
backend changes a rule, the UI silently diverges — bad for governance.
|
||
|
||
Goal: **unify FE/BE policy, cut the number of calls, and make the rules
|
||
transparent/auditable — without coupling the two sides too tightly.** We own the
|
||
backend team.
|
||
|
||
## Options considered
|
||
|
||
| Option | Fewer calls? | Unifies policy? | Cost |
|
||
| -------------------------------------------------------------- | ------------------------- | --------------------------------------------------------- | --------------------------- |
|
||
| 1. Status quo (client calls upstreams, aggregates, owns rules) | No | No | — |
|
||
| 2. Unified client API layer (one facade in the FE) | No — still N round-trips | No — rules still on client | Low, but misses the goal |
|
||
| **3. Screen-shaped endpoints on our own backend ("BFF-lite")** | **Yes** — 1 call/screen | **Yes** — server computes decisions | Low–medium |
|
||
| 4. Separately-deployed BFF service | Yes | Yes | Medium — another deployable |
|
||
| 5. GraphQL gateway | Yes (client picks fields) | **No, not by itself** — still need resolvers to own rules | Medium–high; new infra |
|
||
|
||
GraphQL solves over/under-fetching but does not, on its own, move rules
|
||
server-side — and our problem is policy unification + drift, not field-selection
|
||
flexibility. Option 4 is option 3 with a deployment boundary added.
|
||
|
||
## Decision
|
||
|
||
**Screen-shaped ("BFF-lite") endpoints that return decision-enriched DTOs, defined
|
||
by a single shared contract. The frontend renders decisions; it does not recompute
|
||
them.** Keep it minimal: implement BFF-shaped endpoints on the backend we already
|
||
own. Promote to a separately-deployed BFF service only when a second consumer
|
||
(mobile/partner) or a team boundary demands it — not before.
|
||
|
||
### Why DTOs _decouple_ rather than couple
|
||
|
||
The coupling people fear comes from **not** having DTOs — i.e. serializing internal
|
||
DB/domain entities straight onto the wire, so every schema change ripples to the
|
||
client. A DTO is the decoupling seam:
|
||
|
||
```
|
||
DB entity / domain model → DTO (the wire contract) → FE view model
|
||
(backend's own) (the agreed contract) (frontend's own)
|
||
```
|
||
|
||
Each side keeps its own internal model and refactors freely; only the DTO is a
|
||
deliberate, versioned change. The one coupling that remains — both sides agreeing
|
||
on the contract — is the _wanted_, reviewable seam. Manage it with **one source of
|
||
truth** (OpenAPI or TypeSpec) that **generates types for both sides**. That spec is
|
||
the governance/transparency artifact.
|
||
|
||
### Two shapes of "policy over the wire" — pick per rule
|
||
|
||
- **Config value** — for simple thresholds. Server sends the value; the FE applies
|
||
it for instant feedback; **the backend re-validates on submit as the authority.**
|
||
Example here: the scholing threshold.
|
||
- **Decision flag** — for anything non-trivial/sensitive. Server computes the
|
||
boolean (optionally with a `reason`); the FE just renders it. Example here:
|
||
herregistratie eligibility.
|
||
|
||
The frontend keeps only **format** validation (postcode shape, integer parsing) for
|
||
instant feedback — never as the authority.
|
||
|
||
### Where the contract lives, after codegen
|
||
|
||
The paragraph above says "manage it with one source of truth that generates types for
|
||
both sides". That target state has arrived, so this section states which artifact is now
|
||
the contract.
|
||
|
||
**The generated client is the wire contract.** `libs/shared/src/infrastructure/api-client.ts`
|
||
is regenerated from the backend's OpenAPI document by `npm run gen:api`, and CI fails on
|
||
drift (the `api-client-drift` job regenerates it and runs `git diff --exit-code`). It is the
|
||
single source of truth for the shape of every endpoint. An adapter consumes its types
|
||
directly; 19 of the 20 infrastructure adapters do.
|
||
|
||
**A hand-written `contracts/*.dto.ts` is the exception, for two cases only:**
|
||
|
||
1. **Codegen does not reach the endpoint** — a hand-rolled `fetch`/XHR path that the
|
||
generator never sees.
|
||
2. **The generator types the shape too loosely** — the generated type compiles but is
|
||
weaker than the wire really is.
|
||
|
||
In either case the hand-written file must still import nothing. It describes the wire, not
|
||
the domain.
|
||
|
||
**The `parse*` trust boundary is unchanged and stays mandatory**, whichever way the type
|
||
arrived. A generated type is a compile-time claim about the wire, not a runtime guarantee:
|
||
the server can send anything. `infrastructure/` validates the untrusted shape and maps it
|
||
onto the domain, exactly as before.
|
||
|
||
**The four surviving hand-written contracts stay.** They are
|
||
`apps/ssp/src/app/registratie/contracts/{brp-address,dashboard-view,duo-diplomas}.dto.ts`
|
||
and `libs/beheer/src/contracts/stamdata.dto.ts`. All four fall under case 2, and the
|
||
dashboard view shows why: the generator emits every property as optional, and it flattens
|
||
a discriminated union into a bag of optional fields.
|
||
|
||
```ts
|
||
// generated — every field optional, `tag` a bare string, all variants merged
|
||
interface RegistrationStatusDto {
|
||
tag?: string | undefined;
|
||
herregistratieDatum?: string | undefined;
|
||
geschorstTot?: string | undefined;
|
||
reden?: string | undefined;
|
||
doorgehaaldOp?: string | undefined;
|
||
}
|
||
|
||
// hand-written — a real discriminated union, per-variant fields required
|
||
type RegistrationStatusDto =
|
||
| { tag: 'Geregistreerd'; herregistratieDatum: string }
|
||
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
|
||
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
|
||
```
|
||
|
||
Adopting the generated shape here would push `undefined` handling into every consumer and
|
||
make an illegal state representable, which CLAUDE.md §3 forbids. Retiring these four is
|
||
therefore **not** a cleanup to schedule; it becomes correct only if the backend annotates
|
||
its DTOs so the generator emits required properties and real unions.
|
||
|
||
## Worked example in this POC
|
||
|
||
Implemented against the real backend, `backend/src/BigRegister.Api`. Two slices demonstrate
|
||
**both** policy shapes.
|
||
|
||
**A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).**
|
||
|
||
- Endpoint: `GET /api/v1/dashboard-view` (`Program.cs`), one call replacing three.
|
||
- Contract: `apps/ssp/src/app/registratie/contracts/dashboard-view.dto.ts`
|
||
(`DashboardViewDto` = registration + person + `decisions`).
|
||
- Boundary parse: `parseDashboardView()` in
|
||
`apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
|
||
untrusted shape and maps DTO → domain (hand-written; no schema lib).
|
||
- `BigProfileStore` derives `profile` and `decisions` from the single validated view (was a
|
||
3-resource `map2`). One request → one consistent snapshot.
|
||
- `herregistratie.page.ts` reads `decisions.eligibleForHerregistratie` instead of computing
|
||
it client-side. That rule is server-owned: it lives only in `HerregistratieRule.cs`, with
|
||
no FE mirror to drift from it (WP-75).
|
||
|
||
**B. Intake scholing threshold → config value.**
|
||
|
||
- Endpoint: `GET /api/v1/intake/policy` (`Program.cs`), serving
|
||
`IntakePolicy.ScholingThreshold`.
|
||
- Contract: the generated `IntakePolicyDto`; the adapter is
|
||
`apps/ssp/src/app/herregistratie/infrastructure/intake-policy.adapter.ts`.
|
||
- `intake.machine.ts`: the hardcoded `LAGE_UREN_DREMPEL` constant is gone;
|
||
`lageUren(a, scholingThreshold)` and validation take the value, which lives in machine
|
||
state and is set via a `SetPolicy` message. A `SCHOLING_THRESHOLD_DEFAULT` remains only
|
||
as the offline fallback.
|
||
- `intake-wizard.component.ts` fetches the policy and dispatches `SetPolicy`.
|
||
- WP-69: the backend re-validates the threshold as the authority on submit —
|
||
`IntakePolicy.RejectIncompleteScholing` runs before `POST /applications/{id}/submit`
|
||
(intake-typed) writes anything, 400ing an incomplete scholing answer instead of silently
|
||
accepting a crafted POST that skips it. (WP-72 deleted the legacy `POST /intakes` endpoint
|
||
this once also covered — deleting the surface is a stronger fix than 400ing on it.)
|
||
|
||
## Migration sequence (for the real app)
|
||
|
||
1. Define the contract in OpenAPI/TypeSpec; generate types for FE and BE.
|
||
2. Stand up screen-shaped endpoints on the existing backend that aggregate the
|
||
upstreams and compute `decisions`.
|
||
3. Point each screen at its single endpoint; delete client-side aggregation.
|
||
4. Move each hardwired rule server-side; expose as decision flag or config value.
|
||
5. Reduce the FE to format-validation + rendering.
|
||
|
||
## Out of scope here (next steps, not built in the worked example)
|
||
|
||
- Optimistic-update race fix in `BigProfileStore`
|
||
(`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
|
||
concurrent submits).
|
||
- Multi-tab session sync. The session itself now persists (`localStorage`, read back
|
||
through `parseStoredPrincipal`), but a change in one tab does not reach another — no
|
||
`storage` listener exists.
|
||
|
||
Two bullets were discharged and removed. Runtime DTO validation is no longer "only the
|
||
dashboard view": 33 `parse*` boundary functions exist. The OpenAPI codegen toolchain is
|
||
real: `npm run gen:api` generates `libs/shared/src/infrastructure/api-client.ts` and CI
|
||
drift-checks it.
|
||
|
||
ponytail: build the pattern once on one slice; copy it across screens when the real
|
||
backend lands, rather than scaffolding all of it up front.
|