Files
atomic-design-poc/docs/architecture/0001-bff-lite-decision-dtos.md
Edwin van den Houdt e82309786d style: format frontend, docs and skills with prettier; add .prettierignore
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>
2026-07-03 13:39:31 +02:00

7.0 KiB
Raw Permalink Blame History

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