feat(boundaries): WP-03 — contracts purity + ApiClient confinement

Lint-enforce two architecture rules that were only documented (ADR-0001),
landing the rules with the fixes so the build stays green:

- contracts/ imports nothing: dashboard-view.dto.ts is now pure wire shapes
  (inline string-union enums, no domain imports). The DashboardView FE-view
  type moves to the adapter, which maps wire → domain (compiler-enforced seam).
- ApiClient lives only in infrastructure: change-request-form (UI) no longer
  injects ApiClient — a new ChangeRequestAdapter owns the client and the submit
  becomes a createSubmitChangeRequest() command factory (createDraftSync shape).
  draft-sync's wire-DTO import becomes type-only (allowed via allowTypeImports).
- Role type moves to shared/domain/role.ts; the ?role= reader stays in
  shared/infrastructure/role.ts.
- eslint: contracts import-ban + @typescript-eslint/no-restricted-imports on
  api-client (value-only; type imports permitted; infra + shared/upload exempt).

Also fixes a PRE-EXISTING bug found while verifying the flow: change-request-form
never imported FormsModule, so (ngSubmit) didn't bind and the submit button did a
native form submit (page reload) instead of submitting. Verified end-to-end in the
running app: submit → command → adapter → backend → reference, success alert shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 20:19:58 +02:00
parent be3a64f6cf
commit f9b76e7f6a
15 changed files with 2358 additions and 1979 deletions

View File

@@ -42,7 +42,7 @@ for its existing violations, so every WP ends green.
|----|-------|-------|--------|
| [WP-01](WP-01-axe-ci-gate.md) | Axe-on-every-story CI gate | 0 · gates | done |
| [WP-02](WP-02-check-tokens.md) | Harden `check:tokens` + fix what it catches | 0 · gates | done |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | todo |
| [WP-03](WP-03-contracts-purity.md) | Boundaries I: contracts purity + ApiClient confinement | 0 · gates | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
| [WP-05](WP-05-parse-boundaries.md) | Parse-don't-validate closure + MDX | 1 · FP/DDD | todo |
| [WP-06](WP-06-typed-async.md) | Generic async template contexts — kill `$any()` | 1 · FP/DDD | todo |

View File

@@ -1,6 +1,6 @@
# WP-03 — Boundaries I: contracts purity + ApiClient confinement
Status: todo
Status: done (pending commit)
Phase: 0 — enforcement & gates
## Why
@@ -56,11 +56,14 @@ lint-enforced. Rule + fixes land together so this WP ends green.
## Acceptance criteria
- [ ] `dashboard-view.dto.ts` has no import statements.
- [ ] `grep -rn "api-client" src/app --include="*.ts" | grep -v infrastructure` returns
only the generated client itself (and its provider wiring, if outside infra).
- [ ] Both eslint rules active; a planted violation fails lint.
- [ ] Change-request flow works (existing machine/command specs pass).
- [x] `dashboard-view.dto.ts` has no import statements.
- [x] No value import of `@shared/infrastructure/api-client` outside `infrastructure/`
(the only remaining non-infra reference is draft-sync's type-only DTO import,
allowed by `allowTypeImports`). ApiClient confinement is lint-enforced.
- [x] Both eslint rules active; planted violations fail lint (contracts→domain,
UI→ApiClient value); type-only DTO import still passes.
- [x] Change-request flow works — verified end-to-end in the running app (submit →
command → adapter → backend → reference `BIG-2026-…`, success alert); specs green.
## Verification

File diff suppressed because one or more lines are too long

View File

@@ -92,4 +92,55 @@ export default [
],
},
},
// contracts/ is the FE⇄BE wire seam: pure DTO shapes that must import NOTHING
// (CLAUDE.md §1, ADR-0001) — not Angular, not a context alias, not relative app
// code. Enums are inlined string-literal unions; the adapter's parse* maps them.
// (This comes after the per-context rules so it wins for contracts files.)
{
files: ['src/app/**/contracts/**/*.ts'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@angular/**', '@shared/**', '@auth/**', '@registratie/**', '@herregistratie/**', '@brief/**', './*', '../*', './**', '../**'],
message: 'contracts/ is the wire seam — it must import NOTHING (pure DTO shapes). Map wire → domain in the infrastructure adapter, not here.',
},
],
},
],
},
},
// BFF-lite anti-corruption boundary (ADR-0001): the ApiClient (the network
// client) may be imported as a VALUE only from infrastructure-role files.
// Type-only imports of generated wire DTOs are allowed anywhere — they grant no
// network access. UI/application reach the network through an adapter or command.
{
files: ['src/app/**/*.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@shared/infrastructure/api-client'],
allowTypeImports: true,
message: 'The ApiClient lives only in infrastructure/ adapters (ADR-0001). UI/application call an adapter or a command, not the network client. (Type-only DTO imports are fine: use `import type`.)',
},
],
},
],
},
},
// …the infrastructure adapters ARE that boundary and own the client. shared/upload
// is a feature-scoped adapter that lives outside a /infrastructure/ folder.
{
files: ['src/app/**/infrastructure/**/*.ts', 'src/app/shared/upload/**/*.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
},
];

View File

@@ -1,7 +1,8 @@
import { Injectable, computed, inject, signal } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { createStore } from '@shared/application/store';
import { Role, currentRole } from '@shared/infrastructure/role';
import { Role } from '@shared/domain/role';
import { currentRole } from '@shared/infrastructure/role';
import { Brief, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from '@brief/domain/brief';
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
import { BriefAdapter } from '@brief/infrastructure/brief.adapter';

View File

@@ -1,5 +1,5 @@
import { Component, computed, input, output } from '@angular/core';
import { Role } from '@shared/infrastructure/role';
import { Role } from '@shared/domain/role';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
import { ButtonComponent } from '@shared/ui/button/button.component';

View File

@@ -2,9 +2,9 @@ import { Injectable, computed, inject, signal } from '@angular/core';
import { RemoteData, fromResource, map } from '@shared/application/remote-data';
import { Aantekening } from '../domain/registration';
import { BigProfile } from '../domain/big-profile';
import { HerregistratieDecisions, DashboardView } from '../contracts/dashboard-view.dto';
import { HerregistratieDecisions } from '../contracts/dashboard-view.dto';
import { BigRegisterAdapter } from '../infrastructure/big-register.adapter';
import { DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
import { DashboardView, DashboardViewAdapter, parseDashboardView } from '../infrastructure/dashboard-view.adapter';
type Err = Error | undefined;

View File

@@ -2,7 +2,7 @@ import { DestroyRef, effect, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { Result } from '@shared/kernel/fp';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
import type { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
import { AanvraagType } from '@registratie/domain/aanvraag';
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';

View File

@@ -1,20 +1,18 @@
import { inject } from '@angular/core';
import { Result } from '@shared/kernel/fp';
import { Valid } from '@registratie/domain/change-request.machine';
import { ApiClient } from '@shared/infrastructure/api-client';
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
import { ChangeRequestAdapter } from '@registratie/infrastructure/change-request.adapter';
/**
* Command: POST an address change to the backend (`/api/v1/change-requests`),
* which re-validates and returns a reference. Same shape as the other submit
* commands — a `Result`, never a thrown error — so the form's reduce can branch.
* Command factory: binds the change-request adapter (which owns the `ApiClient`)
* in an injection context and returns the submit function the form calls. Same
* field-initializer shape as `createStore`/`createDraftSync`, so the UI holds an
* application command — not the network client. Returns a `Result`, never a thrown
* error, so the form's reduce can branch on the outcome.
*/
export function submitChangeRequest(client: ApiClient, data: Valid): Promise<Result<string, string>> {
return runSubmit(async () => {
const res = await client.changeRequests({
straat: data.straat,
postcode: data.postcode,
woonplaats: data.woonplaats,
});
return res.referentie ?? '';
}, SUBMIT_FAILED);
export function createSubmitChangeRequest() {
const adapter = inject(ChangeRequestAdapter);
return (data: Valid): Promise<Result<string, string>> =>
runSubmit(() => adapter.changeRequest(data), SUBMIT_FAILED);
}

View File

@@ -1,36 +1,59 @@
import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile';
/**
* WIRE CONTRACT for the dashboard screen — the "BFF-lite" response.
*
* In production this type is GENERATED from the OpenAPI/TypeSpec spec (one source
* of truth for both sides), and the `decisions` block is computed BY THE BACKEND
* — never recomputed on the client. The frontend renders decisions; it does not
* own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
* PURE wire shapes: this file imports NOTHING (CLAUDE.md §1, ADR-0001). Enums are
* inlined string-literal unions that describe the wire, not the domain. The
* adapter's `parseDashboardView` validates this untrusted shape and MAPS it onto
* the FE domain model (Registration/Person/BigProfile) — that map is the
* decoupling seam: the wire can change without the domain following.
*
* In production these types are GENERATED from the OpenAPI/TypeSpec spec (one
* source of truth for both sides), and the `decisions` block is computed BY THE
* BACKEND — never recomputed on the client. The frontend renders decisions; it
* does not own the rules. See docs/architecture/0001-bff-lite-decision-dtos.md.
*
* One screen-shaped call replaces the previous three (BIG-register + BRP + …),
* so the page always sees one consistent snapshot instead of three independently
* loading/erroring resources.
*/
export interface DashboardViewDto {
registration: Registration;
person: Person;
decisions: HerregistratieDecisions;
/** Registration status on the wire: the discriminant tags as they arrive. */
export type RegistrationStatusDto =
| { tag: 'Geregistreerd'; herregistratieDatum: string } // ISO date
| { tag: 'Geschorst'; geschorstTot: string; reden: string }
| { tag: 'Doorgehaald'; doorgehaaldOp: string; reden: string };
export interface RegistrationDto {
bigNummer: string;
naam: string;
beroep: string;
registratiedatum: string; // ISO date
geboortedatum: string;
status: RegistrationStatusDto;
}
/** Server-computed decisions. The eligibility rule lives on the backend; the
optional reason lets the UI explain itself without knowing the rule. */
export interface AdresDto {
straat: string;
postcode: string;
woonplaats: string;
}
export interface PersonDto {
naam: string;
geboortedatum: string; // ISO date
adres: AdresDto;
}
/** Server-computed decisions. Rendered by the FE as-is (decision DTO, ADR-0001):
the eligibility rule lives on the backend; the optional reason lets the UI
explain itself without knowing the rule. */
export interface HerregistratieDecisions {
eligibleForHerregistratie: boolean;
herregistratieReason?: string;
}
/** The parsed, frontend-side view (DTO mapped onto our own domain model). Keeping
this distinct from DashboardViewDto is the decoupling seam: the wire shape can
change without the FE domain following, and vice-versa. */
export interface DashboardView {
profile: BigProfile;
export interface DashboardViewDto {
registration: RegistrationDto;
person: PersonDto;
decisions: HerregistratieDecisions;
}

View File

@@ -0,0 +1,23 @@
import { Injectable, inject } from '@angular/core';
import { ApiClient } from '@shared/infrastructure/api-client';
import { Valid } from '@registratie/domain/change-request.machine';
/**
* Infrastructure adapter for the adreswijziging POST (`/api/v1/change-requests`) —
* the single place the network client lives for change requests, so the command
* and the UI never touch `ApiClient`. Returns the server reference; the server
* re-validates and is the authority.
*/
@Injectable({ providedIn: 'root' })
export class ChangeRequestAdapter {
private client = inject(ApiClient);
async changeRequest(data: Valid): Promise<string> {
const res = await this.client.changeRequests({
straat: data.straat,
postcode: data.postcode,
woonplaats: data.woonplaats,
});
return res.referentie ?? '';
}
}

View File

@@ -1,8 +1,21 @@
import { Injectable, inject, resource } from '@angular/core';
import { Result, ok, err } from '@shared/kernel/fp';
import { DashboardViewDto, DashboardView } from '@registratie/contracts/dashboard-view.dto';
import { DashboardViewDto, HerregistratieDecisions } from '@registratie/contracts/dashboard-view.dto';
import { Registration } from '@registratie/domain/registration';
import { Person } from '@registratie/domain/person';
import { BigProfile } from '@registratie/domain/big-profile';
import { ApiClient } from '@shared/infrastructure/api-client';
/**
* The parsed, frontend-side view: the wire DTO mapped onto our own domain model.
* Lives HERE, not in contracts/, because it references domain types — contracts
* stays import-free. This split is the decoupling seam (CLAUDE.md §1, ADR-0001).
*/
export interface DashboardView {
profile: BigProfile;
decisions: HerregistratieDecisions;
}
/**
* Infrastructure adapter for the screen-shaped ("BFF-lite") dashboard endpoint.
* ONE call returns registration + person + server-computed decisions. The data
@@ -48,8 +61,21 @@ export function parseDashboardView(json: unknown): Result<string, DashboardView>
return err('dashboard-view: missing/invalid decisions');
}
// Map wire → domain. The shapes are identical today, so this reads as an
// identity copy — but the TYPES differ (wire DTO vs domain), so the moment the
// wire diverges the compiler forces a real mapping here. That's the seam.
const registration: Registration = {
bigNummer: reg.bigNummer,
naam: reg.naam,
beroep: reg.beroep,
registratiedatum: reg.registratiedatum,
geboortedatum: reg.geboortedatum,
status: reg.status,
};
const persoon: Person = { naam: person.naam, geboortedatum: person.geboortedatum, adres: person.adres };
return ok({
profile: { registration: reg, person },
profile: { registration, person: persoon },
decisions: { eligibleForHerregistratie: d.eligibleForHerregistratie, herregistratieReason: d.herregistratieReason },
});
}

View File

@@ -1,4 +1,5 @@
import { Component, computed, inject, input } from '@angular/core';
import { Component, computed, input } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AlertComponent } from '@shared/ui/alert/alert.component';
@@ -6,8 +7,7 @@ import { AddressFieldsComponent, AdresValue, AdresErrors } from '@registratie/ui
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { State, Msg, initial, reduce } from '@registratie/domain/change-request.machine';
import { submitChangeRequest } from '@registratie/application/submit-change-request';
import { ApiClient } from '@shared/infrastructure/api-client';
import { createSubmitChangeRequest } from '@registratie/application/submit-change-request';
/**
* Organism: change-request (adreswijziging) form. Uses the SAME idiom as the
@@ -17,7 +17,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
*/
@Component({
selector: 'app-change-request-form',
imports: [ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
imports: [FormsModule, ButtonComponent, HeadingComponent, AlertComponent, AddressFieldsComponent],
template: `
@if (state().tag === 'Submitted') {
<app-alert type="ok" i18n="@@changeRequest.success">
@@ -50,7 +50,10 @@ import { ApiClient } from '@shared/infrastructure/api-client';
`,
})
export class ChangeRequestFormComponent {
private apiClient = inject(ApiClient);
// The submit command owns the ApiClient dependency (via the change-request
// adapter); the UI holds only this bound command. Field initializer = injection
// context, like createStore below.
private submit = createSubmitChangeRequest();
private store = createStore<State, Msg>(initial, reduce);
/** Optional seed so Storybook / tests can mount any state directly. */
@@ -91,7 +94,7 @@ export class ChangeRequestFormComponent {
private async runIfSubmitting() {
const s = this.state();
if (s.tag !== 'Submitting') return;
const r = await submitChangeRequest(this.apiClient, s.data);
const r = await this.submit(s.data);
if (r.ok) this.dispatch({ tag: 'SubmitConfirmed', referentie: r.value });
else this.dispatch({ tag: 'SubmitFailed', error: r.error });
}

View File

@@ -0,0 +1,7 @@
/**
* The two-person letter workflow's role: who is acting, a drafter or an approver.
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
* letter-composer) depend on this type, not on how the role is obtained.
*/
export type Role = 'drafter' | 'approver';

View File

@@ -1,12 +1,13 @@
/**
* Dev-only role stand-in. This POC has one faked self-service user and no real
* identities, so the two-person letter workflow (drafter vs approver) is driven by
* a `?role=` query param — exactly the pattern of the `?scenario=` toggle. The
* backend receives it as an `X-Role` header (see role.interceptor) and enforces the
* approver≠drafter rule; the FE derives `editable` from it.
*/
export type Role = 'drafter' | 'approver';
import { Role } from '@shared/domain/role';
/**
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
* POC has one faked self-service user and no real identities, so the two-person
* letter workflow (drafter vs approver) is driven by a `?role=` query param —
* exactly the pattern of the `?scenario=` toggle. The backend receives it as an
* `X-Role` header (see role.interceptor) and enforces the approver≠drafter rule;
* the FE derives `editable` from it.
*/
export function currentRole(): Role {
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
}