Applications, documents (+ audit log) and the brief move off static in-memory Dictionaries onto a real SQLite file via EF Core, so demo data survives a process restart or `docker compose restart api` for the first time. The three stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public signatures and static-class shape — no DI, no async ripple into Program.cs's minimal-API handlers — each method just opens a short-lived AppDbContext via Db.Create() under the same lock it already had. Opaque nested shapes (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as JSON text columns rather than redesigned into relational tables, matching the existing "don't interpret it" posture. Found two things the WP's own text got wrong, corrected in docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which stay in-memory) so there's no seed step; and no new docker-compose volume is needed since the existing bind mount already covers the SQLite file — verified against this environment's real podman-backed compose stack, not just by reading the file. Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own transitive default bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption advisory); found and fixed a real xUnit test race where concurrent test-class hosts stomped a shared static connection-string field, fixed by disabling cross-class test parallelization rather than adding DI the stores don't otherwise need. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
12 KiB
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 is styled with the CIBG Huisstijl design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003), 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. A system-font stack 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
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
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 CIBG Huisstijl
(Bootstrap 5.2) CSS classes (btn, form-control, card, …) — 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 stylesheet + a token bridge.
The look comes from CIBG Huisstijl, vendored under public/cibg-huisstijl/ and
loaded via a <link> in index.html; body.brand--cibg activates CIBG's
robijn/lintblauw palette. src/styles.scss is a token bridge mapping the app's
semantic --rhc-* token vocabulary onto CIBG/--bs-* values, so components keep
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
whole app, no component changes (ADR-0003). npm run check:tokens fails the build
on any hardcoded colour outside that bridge.
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
isEmptypredicate). - 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: CIBG Huisstijl (customized Bootstrap 5.2) vendored in
public/cibg-huisstijl/, loaded via<link>;src/styles.scssholds the--rhc-*→ CIBG/--bs-*token bridge (ADR-0003). No styling npm dependency. - Data: ASP.NET Core backend (
backend/, EF Core/SQLite-persisted; BRP/DUO reference data stays 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. .npmrcsetslegacy-peer-deps=truebecause@storybook/angular's peer range lags Angular 22; the builder runs fine (build verified).- i18n: every user-facing string is
$localize-wrapped with a stable@@id(source localenl).npx ng build --localize(CI runs this) builds bothnlanden— a genuine second-locale build, not just an unexercised claim — intodist/atomic-design-poc/browser/{nl,en}/;ng serve --configuration=enserves the English build locally.src/locale/messages.en.xlfis real (if demo-quality) English, not machine-untranslated placeholders;angular.json'si18nMissingTranslation: "error"fails the build if a new$localizestring ships without a translation.npm run extract-i18nregenerates thenlreference file.
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 production-grade database (Postgres/SQL
Server — SQLite persists applications/documents/the brief + a real audit table,
see backend/README.md, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
stack; text wordmark). (The backend itself is implemented.) i18n's build seam is
proven (see above) but
production-quality translation, a runtime locale switcher, and RTL/pluralization
edge cases are not — the en file is demo-quality, and locale is a build-time
choice, not a switch in the running app.