Edwin van den Houdt f9b76e7f6a 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>
2026-07-02 20:19:58 +02:00

BIG-register Self-Service Portal — Atomic Design POC

A small Angular app that shows how atomic design makes a frontend cheap to build, reuse and extend. The domain is the BIG-register self-service portal (the Dutch register of healthcare professionals, run by CIBG). It looks like an NL Design System app, branded Rijkshuisstijl, and demonstrates a robust async-state pattern where the UI can never reach an inconsistent state.

Demo / POC — no real login (DigiD is faked) and synthetic seed data. The business rules and data are served by a real ASP.NET Core backend (backend/) consumed through a generated typed client, so the BFF + DDD design is demonstrable, not hand-waved. Free Fira Sans stands in for the licensed Rijksoverheid font and a text wordmark for the logo.


Run it

docker compose up   # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger

Or run the two halves separately:

npm install
npm start            # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
# in another terminal:
cd backend && dotnet run --project src/BigRegister.Api   # API → http://localhost:5000/swagger

npm run storybook    # component library, organized by atomic layer
npm run gen:api      # regenerate the typed API client from the backend OpenAPI doc

Flow: Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake. The backend hosts the business rules (profession derivation, policy questions, eligibility, thresholds); see backend/README.md.

New here: a branching intake questionnaire (/intake) where later questions appear based on earlier answers and progress survives a page reload, plus a visual walkthrough of the state-management ideas. See docs/ARCHITECTURE.md for diagrams (atomic-design pyramid, the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a section on connecting to a .NET backend.

See every data state (scenario toggle)

Append ?scenario= to any data page (e.g. /dashboard) to force an async state:

URL What you see
/dashboard real data (fast)
/dashboard?scenario=slow skeletons for ~2.5s, then data
/dashboard?scenario=loading the loading state, held open
/dashboard?scenario=empty "geen gegevens" empty state
/dashboard?scenario=error error message + Opnieuw proberen (retry)

How atomic design works here (folder = layer)

Atomic design organizes UI into five layers, each built from the one below. In this repo the folder structure is the hierarchy (src/app/):

Layer What it is Examples here
atoms/ smallest building blocks; wrap one design-system element button, text-input, heading, link, alert, status-badge, spinner, skeleton
molecules/ a few atoms combined into a unit form-field (label + input + error), data-row, async (state wrapper)
organisms/ larger, self-contained sections site-header, site-footer, login-form, registration-summary, registration-table, change-request-form
templates/ page skeletons that define layout; content is projected in page-layout (header/content/footer chrome), page-shell (back-link + heading + intro + content)
pages/ a template filled with real data login, dashboard, registration-detail, herregistratie

Each atom is a thin Angular standalone component that applies the Utrecht/Rijkshuisstijl CSS classes — so the design system does the visual work and we only own a small, typed component API.


Where you actually notice the benefit

1. Reuse — the same blocks appear everywhere.

Component Appears in
button login, change-request, herregistratie, async retry, Storybook
form-field + text-input login form and change-request and herregistratie
status-badge dashboard summary, detail summary
page-shell / page-layout all four pages
site-header / site-footer every page
async + skeleton dashboard, detail

Change a component once and every screen that uses it updates.

2. A whole new page = composition, no new components. pages/herregistratie/herregistratie.page.ts is a complete new flow assembled entirely from existing atoms/molecules/templates — zero new building blocks. The branching intake wizard went further: it needed only one new atom (radio-group) and one new organism (intake-wizard); the form fields, buttons, alerts, spinner and page shell were all reused. That's the payoff: new screens cost almost nothing.

3. Templates remove per-page boilerplate. Every page used to repeat its own back-link + heading + intro markup. page-shell captures that once; pages now read like <app-page-shell heading="…" backLink="…">….

4. Theming is one import. The look comes from @rijkshuisstijl-community/design-tokens. src/styles.scss imports the lintblauw palette and applies rhc-theme lintblauw on <body>. Swap the palette import to re-theme the whole app — no component changes.


State management (no impossible states)

Data fetching uses Angular's native, signal-based resource over the generated typed client (no NgRx, no extra dependency). Each context's infrastructure/*.adapter.ts exposes a resource that carries status(), value(), error() and reload() as signals, and a parse* function validates the response at the trust boundary (DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed decisions the FE renders rather than recomputes (see ADR-0001).

The molecule <app-async> turns those signals into UI. It renders exactly one of four slots, chosen by a single computed — so loading, empty, error and loaded are mutually exclusive by construction. You cannot render data and an error at the same time, or show stale content during a hard failure: those states are unrepresentable.

<app-async [resource]="reg" [isEmpty]="regEmpty">
  <ng-template appAsyncLoaded let-r>  <app-registration-summary [reg]="r" /> </ng-template>
  <ng-template appAsyncLoading>       <app-skeleton [count]="6" />          </ng-template>
  <!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
</app-async>
  • Loaded — your content, with the value.
  • Loading — your skeleton, or a default delayed spinner (only appears after ~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons are also delay-gated. → handles slow vs fast connections.
  • Empty — your message, or a default "Geen gegevens gevonden" (driven by an isEmpty predicate).
  • Error — your template, or a default alert + a retry button that calls resource.reload().

Because each data-fetching page wraps its content in <app-async>, correct loading/empty/error handling is automatic and consistent across the app.


Page transitions

The chrome (templates/shell — header + footer) is persistent: it mounts once and hosts the <router-outlet>, so navigating doesn't re-create it (no white flash). Only the routed content cross-fades, via Angular's native withViewTransitions() — the header/footer get a stable view-transition-name in styles.scss so they're excluded from the fade. prefers-reduced-motion disables the animation; non-Chromium browsers degrade to an instant navigation.

Tech notes

  • Angular 22 (standalone components, signals, httpResource, view transitions, control flow @if/@for).
  • Styling: @rijkshuisstijl-community/{design-tokens,components-css} (Utrecht + RHC CSS, pre-themed Rijkshuisstijl) — imported in src/styles.scss, no hand-written theme.
  • Data: ASP.NET Core backend (backend/, in-memory seeded) exposed via an OpenAPI contract; the FE consumes an NSwag-generated typed client (npm run gen:api). The ?scenario= toggle (shared/infrastructure/scenario.interceptor.ts) is dev-only — it is not wired into production builds.
  • .npmrc sets legacy-peer-deps=true because @storybook/angular's peer range lags Angular 22; the builder runs fine (build verified).

Dependency security

The shipped app has 0 known vulnerabilities (npm audit --omit=dev). All advisories live in dev/build tooling (Storybook + the Angular build chain) and never reach the bundle. package.json overrides pin patched transitive versions, taking the full audit from 16 (incl. 3 high) down to 5 low — the remainder all cascade from @babel/core's low-severity sourceMappingURL issue, which only "fixes" by jumping to Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately left. We do not run npm audit fix --force: its proposed fix downgrades Angular 22 → 21.

Deliberately out of scope (POC)

Real auth/DigiD, real BRP/DUO upstreams, a database/persisted audit store, i18n, NgRx, licensed Rijkshuisstijl fonts/logo. (The backend itself is implemented.)

Description
No description provided
Readme 7.5 MiB
Languages
TypeScript 74.1%
C# 16.2%
MDX 3.6%
CSS 3.1%
SCSS 1.3%
Other 1.6%