One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7.0 KiB
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
httpResources (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 (12months) 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.
Worked example in this POC
This POC has no real backend (static mock JSON + fake submit timers), so the
"BFF output" is a static file; the decisions block stands in for what the backend
would compute. Two slices were implemented to demonstrate both policy shapes:
A. Dashboard profile → one aggregated, decision-enriched call (decision-flag).
- Contract:
src/app/registratie/contracts/dashboard-view.dto.ts(DashboardViewDto= registration + person +decisions). - Endpoint:
public/mock/dashboard-view.json(one call replaces three). - Boundary parse:
parseDashboardView()insrc/app/registratie/infrastructure/dashboard-view.adapter.tsvalidates the untrusted shape and maps DTO → domain (hand-written; no schema lib for one contract). BigProfileStorenow derivesprofileanddecisionsfrom the single validated view (was a 3-resourcemap2). One request → one consistent snapshot.herregistratie.page.tsreadsdecisions.eligibleForHerregistratieinstead of callingisHerregistratieEligible(). That rule is now marked server-owned inregistration.policy.ts(kept as reference impl + unit test; FE no longer calls it).- The unused upstream adapters/mocks (
brp.adapter.ts,registration.json,brp.json) were deleted — those calls live behind the BFF now.
B. Intake scholing threshold → config value.
- Contract:
src/app/herregistratie/contracts/intake-policy.dto.ts. - Endpoint:
public/mock/intake-policy.json({ "scholingThreshold": 1000 }). intake.machine.ts: the hardcodedLAGE_UREN_DREMPELconstant is gone;lageUren(a, scholingThreshold)and validation take the value, which lives in machine state and is set via aSetPolicymessage. ASCHOLING_THRESHOLD_DEFAULTremains only as the offline fallback.intake-wizard.component.tsfetches the policy and dispatchesSetPolicy.
Migration sequence (for the real app)
- Define the contract in OpenAPI/TypeSpec; generate types for FE and BE.
- Stand up screen-shaped endpoints on the existing backend that aggregate the
upstreams and compute
decisions. - Point each screen at its single endpoint; delete client-side aggregation.
- Move each hardwired rule server-side; expose as decision flag or config value.
- Reduce the FE to format-validation + rendering.
Out of scope here (next steps, not built in the worked example)
- Runtime DTO validation on every endpoint (only the dashboard view has it).
- Optimistic-update race fix in
BigProfileStore(beginHerregistratie/rollbackHerregistratiecan leavependingwrong under concurrent submits). - Session persistence / multi-tab sync (
SessionStoreis in-memory). - Real OpenAPI/TypeSpec codegen toolchain.
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.