Files
atomic-design-poc/docs/architecture/0001-bff-lite-decision-dtos.md
Edwin van den Houdt 64385999eb Add registratie wizard, BFF dashboard-view, contracts/value-objects, and architecture docs
Checkpoint of in-progress work: the registration wizard (address prefill,
DUO diploma lookup, policy questions), decision-DTO contracts, parse-don't-
validate value objects, infrastructure adapters, plus CLAUDE.md and the
architecture/ADR docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:23:52 +02:00

123 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 | Lowmedium |
| 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 | Mediumhigh; 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.