feat(boundaries): WP-04 — ui ↛ infrastructure + showcase sanction

Move the two wizard lookups behind application-layer facades so ui/ no longer
injects infrastructure adapters directly:
- RegistratieLookupStore (BRP address + DUO diplomas): owns the resources,
  runs the trust-boundary parse, exposes adresStatus/prefillAdres/duoLookup.
- IntakePolicyStore (scholing threshold): owns the policy resource, exposes
  the derived threshold.

Add the lint rule ui/ + layout/ ↛ **/infrastructure/** (@typescript-eslint
variant so it composes with the base direction rules; stories/specs exempted
as test scaffolding). Add the documented showcase sanction (may read every
context). Fix the docs' inventory: 6 contexts / 5 layers, +brief, +contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:01:42 +02:00
parent 3a5c8f157a
commit 035e785c95
10 changed files with 513 additions and 227 deletions

View File

@@ -34,7 +34,8 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
### 1. DDD: contexts then layers, dependencies point inward
`src/app/<context>/<layer>/`. Contexts: `shared`, `auth`, `registratie`,
`herregistratie`, `showcase` (teaching page, not a feature).
`herregistratie`, `brief` (letter-composition teaching slice), `showcase` (teaching
page, not a feature; **sanctioned** to read every context — nothing imports it).
| Layer | Job | Angular allowed? |
| ----------------- | ----------------------------------------- | -------------------------------- |
@@ -45,9 +46,11 @@ advisories are pinned via `package.json` `overrides`; the shipped bundle audits
| `ui/` | how it looks (components, pages) | yes |
**Dependencies only point inward**: `ui → application → domain`; everyone may use
`shared`; never the reverse. Cross-context only `herregistratie → registratie → shared`,
`auth → shared`. Imports use aliases as direction statements: `@shared/* @auth/*
@registratie/* @herregistratie/*`. `domain/` imports nothing from Angular.
`shared`; never the reverse. `ui`/`layout` never import `infrastructure` directly
(reach data through an application store/command) — lint-enforced. Cross-context only
`herregistratieregistratie → shared`, `auth → shared`, `brief → shared`. Imports use
aliases as direction statements: `@shared/* @auth/* @registratie/* @herregistratie/*
@brief/*`. `domain/` imports nothing from Angular.
### 2. Atomic design: folder = layer

View File

@@ -16,7 +16,7 @@ re-registration — "herregistratie").
---
## 1. The big picture: three "contexts", four "layers"
## 1. The big picture: six "contexts", five "layers"
The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.
@@ -27,9 +27,14 @@ src/app/
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
showcase/ a teaching page; not a real feature
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every context)
```
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
### The atomic-design hierarchy, visualised
The UI is built bottom-up: tiny **atoms** combine into **molecules**, which combine
@@ -53,7 +58,7 @@ organism** (`intake-wizard`) — everything else (`form-field`, `text-input`, `b
`alert`, `spinner`, the page shell) was reused unchanged. That is the payoff of the
hierarchy.
Inside a context you'll see the same four folders. They answer four different
Inside a context you'll see the same five folders. They answer five different
questions:
| Layer | Answers… | May import Angular? | Example here |
@@ -61,14 +66,18 @@ questions:
| `domain/` | What are the business rules and data? | **No** (pure TS) | `registration.ts`, `registration.policy.ts` |
| `application/` | How do we coordinate a task / state? | Yes (signals) | `big-profile.store.ts` |
| `infrastructure/` | Where does data come from? | Yes (HTTP) | `big-register.adapter.ts`, `brp.adapter.ts` |
| `contracts/` | What's the FE⇄BE wire shape? | **No** (pure DTOs) | `dashboard-view.dto.ts` |
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the
other way around. The `domain/` layer imports nothing from Angular, so the
business rules are plain functions you can read and test in isolation.
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).
### Why the `shared/` kernel is split too
@@ -79,7 +88,7 @@ Allowed direction: `herregistratie → registratie → shared`, `auth → shared
- `shared/infrastructure/` — the demo HTTP interceptor.
Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`.
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
---

View File

@@ -43,7 +43,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 | done |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | todo |
| [WP-04](WP-04-ui-not-infrastructure.md) | Boundaries II: `ui ↛ infrastructure` + showcase sanction | 0 · gates | done |
| [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 |
| [WP-07](WP-07-brief-idioms.md) | Brief on the shared idioms + RemoteData MDX | 1 · FP/DDD | todo |

View File

@@ -1,6 +1,6 @@
# WP-04 — Boundaries II: `ui ↛ infrastructure` + showcase sanction
Status: todo
Status: done (pending commit)
Phase: 0 — enforcement & gates
## Why
@@ -54,11 +54,13 @@ Phase 0 — a pure move of wiring, no behavior change.
## Acceptance criteria
- [ ] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions.
- [ ] No `**/ui/**` file imports from `**/infrastructure/**`.
- [ ] Both wizards behave unchanged (specs pass; manual smoke).
- [ ] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
- [x] Rule active; lint green; no disables beyond the documented showcase + debug-state
exemptions. (Probed: a ui→infra import errors.)
- [x] No `**/ui/**` file imports from `**/infrastructure/**` (production; stories/specs
exempted — test scaffolding wires the real client).
- [x] Both wizards behave unchanged (178 specs pass; storybook a11y suite mounts both
wizards green — behaviour is a pure wiring move behind root facades).
- [x] CLAUDE.md and ARCHITECTURE.md list 6 contexts / 5 layers.
## Verification

File diff suppressed because one or more lines are too long

View File

@@ -143,4 +143,44 @@ export default [
plugins: { '@typescript-eslint': tseslint.plugin },
rules: { '@typescript-eslint/no-restricted-imports': 'off' },
},
// ui/ and layout/ are the presentation layer: dependencies point inward
// (ui → application → domain, CLAUDE.md §1), so they must NOT import
// infrastructure/ directly — they reach data through an application store or
// command. (Stories/specs are test scaffolding and may wire the real client.)
// Uses the @typescript-eslint variant so it composes with the base
// no-restricted-imports context-direction rules above (last-wins is per rule name).
{
files: ['src/app/**/ui/**/*.ts', 'src/app/**/layout/**/*.ts'],
ignores: ['**/*.stories.ts', '**/*.spec.ts'],
plugins: { '@typescript-eslint': tseslint.plugin },
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
patterns: [
{
group: [
'@shared/infrastructure/*',
'@auth/infrastructure/*',
'@registratie/infrastructure/*',
'@herregistratie/infrastructure/*',
'@brief/infrastructure/*',
],
allowTypeImports: true,
message: 'ui/ and layout/ must not import infrastructure/ directly (CLAUDE.md §1: ui → application → domain). Reach data through an application store or command. (Type-only DTO imports are fine: use `import type`.)',
},
],
},
],
},
},
// Sanctioned exception: showcase/ is the teaching page whose whole point is showing
// multiple contexts side by side (ARCHITECTURE.md §6). It may read every context;
// nothing imports showcase. Same precedent as the shared/ui/debug-state exemption.
{
files: ['src/app/showcase/**/*.ts'],
rules: { 'no-restricted-imports': 'off' },
},
];

View File

@@ -0,0 +1,19 @@
import { Injectable, computed, inject } from '@angular/core';
import { SCHOLING_THRESHOLD_DEFAULT } from '../domain/intake.machine';
import { IntakePolicyAdapter } from '../infrastructure/intake-policy.adapter';
/**
* Application-layer facade for the server-owned intake policy (the scholing
* threshold config value). It owns the httpResource (created here, in the required
* injection context) and exposes the threshold as a derived signal, falling back to
* the domain default until the backend answers. The UI reaches the network through
* application/, never infrastructure/ directly (CLAUDE.md §1). The backend stays the
* authority and re-validates on submit.
*/
@Injectable({ providedIn: 'root' })
export class IntakePolicyStore {
private policy = inject(IntakePolicyAdapter);
private policyRes = this.policy.policyResource();
readonly scholingThreshold = computed(() => this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT);
}

View File

@@ -25,7 +25,7 @@ import {
SCHOLING_THRESHOLD_DEFAULT,
} from '@herregistratie/domain/intake.machine';
import { createDraftSync } from '@registratie/application/draft-sync';
import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-policy.adapter';
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
@@ -120,13 +120,11 @@ import { IntakePolicyAdapter } from '@herregistratie/infrastructure/intake-polic
})
export class IntakeWizardComponent {
private profile = inject(BigProfileStore);
private policy = inject(IntakePolicyAdapter);
// Server-owned policy (scholing threshold): fetched from the backend via the
// application facade, not hardcoded. The backend stays the authority on submit.
private policyStore = inject(IntakePolicyStore);
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
// Server-owned policy: the scholing threshold is fetched from the backend, not
// hardcoded. The backend stays the authority and re-validates on submit.
private policyRes = this.policy.policyResource();
/** Optional seed so Storybook / the showcase can mount any state directly. */
seed = input<IntakeState>(initial);
@@ -201,7 +199,7 @@ export class IntakeWizardComponent {
// only the policy value; untrack the dispatch (it reads the state signal
// internally, which would otherwise make this effect loop on its own write).
effect(() => {
const scholingThreshold = this.policyRes.value()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT;
const scholingThreshold = this.policyStore.scholingThreshold();
untracked(() => this.dispatch({ tag: 'SetPolicy', scholingThreshold }));
});
}

View File

@@ -0,0 +1,58 @@
import { Injectable, computed, inject } from '@angular/core';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { DuoLookupDto } from '../contracts/duo-diplomas.dto';
import { BrpAdapter, parseBrpAddress } from '../infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '../infrastructure/duo.adapter';
type Err = Error | undefined;
/**
* Application-layer facade for the registratie wizard's two lookups (BRP address,
* DUO diplomas). It owns the httpResources (created here, in the required injection
* context), runs the trust-boundary parse, and exposes RemoteData / derived signals
* — so the UI reaches the network through application/, never infrastructure/
* directly (CLAUDE.md §1: ui → application → domain). Same facade shape as
* BigProfileStore.
*/
@Injectable({ providedIn: 'root' })
export class RegistratieLookupStore {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** BRP lookup outcome. A failure or "geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
readonly adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The address to prefill the draft with, once BRP resolves with a found address;
null otherwise (loading, error, no address, malformed). */
readonly prefillAdres = computed<{ straat: string; postcode: string; woonplaats: string } | null>(() => {
const json = this.adresRes.value();
if (json === undefined) return null;
const parsed = parseBrpAddress(json);
return parsed.ok && parsed.value.gevonden && parsed.value.adres ? parsed.value.adres : null;
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
readonly duoLookup = computed<RemoteData<Err, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** Reload the BRP lookup (e.g. when the wizard restarts) so the address re-prefills. */
reloadAdres() {
this.adresRes.reload();
}
}

View File

@@ -14,9 +14,8 @@ import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store';
import { whenTag } from '@shared/kernel/fp';
import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
import { DuoAdapter, parseDuoLookup } from '@registratie/infrastructure/duo.adapter';
import { RemoteData } from '@shared/application/remote-data';
import { RegistratieLookupStore } from '@registratie/application/registratie-lookup.store';
import { DuoLookupDto, PolicyQuestionDto } from '@registratie/contracts/duo-diplomas.dto';
import {
RegistratieState,
@@ -194,8 +193,7 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
`,
})
export class RegistratieWizardComponent {
private brp = inject(BrpAdapter);
private duo = inject(DuoAdapter);
private lookup = inject(RegistratieLookupStore);
private uploadAdapter = inject(UploadAdapter);
private store = createStore<RegistratieState, RegistratieMsg>(initial, reduce);
@@ -204,9 +202,6 @@ export class RegistratieWizardComponent {
protected previewUrlFor = (documentId: string): string | undefined =>
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
protected adresRes = this.brp.adresResource();
private diplomasRes = this.duo.diplomasResource();
/** Optional seed so Storybook / tests can mount any state directly. */
seed = input<RegistratieState>(initial);
@@ -286,25 +281,10 @@ export class RegistratieWizardComponent {
protected correspondentieLabel = computed(() => ({ email: $localize`:@@regWizard.corr.email:Per e-mail`, post: $localize`:@@regWizard.corr.post:Per post` }[this.draft().correspondentie ?? 'post']));
protected diplomaHerkomstLabel = computed(() => ({ duo: $localize`:@@regWizard.herkomst.diplomaDuo:Geverifieerd via DUO`, handmatig: $localize`:@@regWizard.herkomst.diplomaHandmatig:Handmatig ingevoerd (wordt beoordeeld)` }[this.draft().diplomaHerkomst ?? 'handmatig']));
/** BRP lookup outcome. A failure or"geen adres" never blocks the wizard — both
fall back to manual entry (PRD §7). 'geen' covers both no-address-found and a
malformed response; 'fout' is an unreachable BRP. */
protected adresStatus = computed<'laden' | 'gevonden' | 'geen' | 'fout'>(() => {
const st = this.adresRes.status();
if (st === 'loading' || st === 'reloading') return 'laden';
if (st === 'error') return 'fout';
const json = this.adresRes.value();
const parsed = json !== undefined ? parseBrpAddress(json) : null;
return parsed && parsed.ok && parsed.value.gevonden ? 'gevonden' : 'geen';
});
/** The DUO lookup (diplomas + manual fallback), validated at the trust boundary. */
protected lookupRd = computed<RemoteData<Error | undefined, DuoLookupDto>>(() => {
const rd = fromResource(this.diplomasRes);
if (rd.tag !== 'Success') return rd;
const parsed = parseDuoLookup(rd.value);
return parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) };
});
/** BRP lookup outcome (laden/gevonden/geen/fout) and the parsed DUO lookup, both
served by the application facade — the wizard renders, it does not fetch/parse. */
protected adresStatus = this.lookup.adresStatus;
protected lookupRd: () => RemoteData<Error | undefined, DuoLookupDto> = this.lookup.duoLookup;
/** Parsed lookup as a plain value (or null) — used outside the beroep step (the
controle summary) where the <app-async> template variable isn't in scope. */
@@ -362,21 +342,17 @@ export class RegistratieWizardComponent {
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
const seeded = this.seed();
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
// Prefill the address from the BRP lookup as it arrives. Track only the resource
// value; untrack the dispatch (it reads the state signal, which would otherwise
// make this effect loop on its own write). Don't clobber edits/restored data.
// Prefill the address from the BRP lookup as it arrives. Track only the facade's
// parsed prefill signal; untrack the dispatch (it reads the state signal, which
// would otherwise make this effect loop on its own write). Don't clobber
// edits/restored data. A null prefill (loading/error/geen adres) leaves manual entry.
effect(() => {
const json = this.adresRes.value();
if (!json) return;
const parsed = parseBrpAddress(json);
const a = this.lookup.prefillAdres();
if (!a) return;
untracked(() => {
const s = this.state();
if (s.tag !== 'Invullen' || s.draft.straat) return;
// ponytail: slice 2 handles gevonden === false -> manual entry stays handmatig.
if (parsed.ok && parsed.value.gevonden && parsed.value.adres) {
const a = parsed.value.adres;
this.dispatch({ tag: 'PrefillAdres', straat: a.straat, postcode: a.postcode, woonplaats: a.woonplaats });
}
});
});
// A11y: focus management (step heading on step change, error summary on a
@@ -400,7 +376,7 @@ export class RegistratieWizardComponent {
restart() {
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
this.dispatch({ tag: 'Seed', state: initial });
this.adresRes.reload();
this.lookup.reloadAdres();
}
/** The effect: when we enter Indienen, submit through the aanvraag lifecycle