Edwin van den Houdt 556f2f47bf feat(fp): WP-22 — durable persistence (SQLite/EF Core)
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>
2026-07-05 10:19:23 +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 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 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: CIBG Huisstijl (customized Bootstrap 5.2) vendored in public/cibg-huisstijl/, loaded via <link>; src/styles.scss holds 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.
  • .npmrc sets legacy-peer-deps=true because @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 locale nl). npx ng build --localize (CI runs this) builds both nl and en — a genuine second-locale build, not just an unexercised claim — into dist/atomic-design-poc/browser/{nl,en}/; ng serve --configuration=en serves the English build locally. src/locale/messages.en.xlf is real (if demo-quality) English, not machine-untranslated placeholders; angular.json's i18nMissingTranslation: "error" fails the build if a new $localize string ships without a translation. npm run extract-i18n regenerates the nl reference 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.

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%