# 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.