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

## 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()` in
  `src/app/registratie/infrastructure/dashboard-view.adapter.ts` validates the
  untrusted shape and maps DTO → domain (hand-written; no schema lib for one
  contract).
- `BigProfileStore` now 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
  calling `isHerregistratieEligible()`. That rule is now marked server-owned in
  `registration.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 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`.

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

- Runtime DTO validation on **every** endpoint (only the dashboard view has it).
- Optimistic-update race fix in `BigProfileStore`
  (`beginHerregistratie`/`rollbackHerregistratie` can leave `pending` wrong under
  concurrent submits).
- Session persistence / multi-tab sync (`SessionStore` is 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.
