Files
atomic-design-poc/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md
T
ehoandClaude Sonnet 5 e7156c5132 feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects)
plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's
separate sibling repo. That split had already produced real drift: a
hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree
forked and silently diverging (7 files), and beheer + the styles.scss
token bridge duplicated byte-for-byte across both repos.

- git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/,
  environments/, the Storybook docs/*.mdx, and styles.scss into
  libs/shared + libs/beheer (all confirmed identical between the two
  repos before merging). auth stays deliberately duplicated per
  ADR-0002 (actor-specific, expected to diverge) - amended there.
- One generated API client (libs/shared), no more vendored swagger.json.
- .dependency-cruiser split into a base factory + one config per app,
  and Storybook into .storybook-ssp/.storybook-behandelportal - both
  forced by the @auth/* alias resolving to different directories per app.
- SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/
  HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies
  its own nav/admin-links/dev-panel instead of one being hardcoded.
- CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated;
  WP-67 backlog entry documents the full decision trail.

npm run ci green (lint, dep:check x2, 360 tests across ssp/
behandelportal/shared/beheer, both localized builds, backend tests,
snippet + api-client drift); both dev servers, both Storybook
instances, and docker compose verified working.

The old sibling repo (/home/eho/repos/behandelportal) is left
untouched, not deleted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 21:01:57 +02:00

10 KiB

ADR 0002 — User groups as actors, not bounded contexts

Status: Proposed · Date: 2026-07-01

Problem

Today the app knows exactly one actor. auth/domain/session.ts is a flat Session { bsn, naam }, authentication is a faked DigiD flow, and the backend has no role model at all (only an X-Admin: true header seam in Program.cs and a stringly-typed Actor on audit entries). This whole repo is the Zorgverlener self-service portal (SSP).

We now need a second user group — Behandelaar (backoffice: assessing and deciding on applications) — and want room for others later (admin, auditor, institution rep). The question is a modelling one, not a coding one:

How do user groups map onto our DDD structure? Is "Zorgverlener" a bounded context? Is "Behandelaar" a folder next to registratie/herregistratie? Where does "who may do what" live?

Getting this wrong is expensive: split the code by role and every feature smears across "folders per persona"; lump everyone into one users context and it becomes a god-context.

Confirmed constraints (with the product owner):

  • The backoffice is a separate frontend application (own audience, own deployable — see the WP-67 amendment below for where its source actually lives).
  • The groups authenticate differently: Zorgverlener via DigiD/BSN; Behandelaar via employee SSO.
  • Both act on the same underlying aggregate — the aanvraag/registration — but see different views.

Options considered

Option Ubiquitous language respected? Coupling Verdict
1. Split contexts by role (zorgverlener/, behandelaar/ folders) No — role ≠ capability; features smear across personas High Reject
2. One catch-all users/identity context owning everything about people No — becomes a god-context; mixes identity, authz, and features High Reject
3. Actors are personas; contexts are capabilities; identity is typed Yes Low Adopt

Decision

A user group is an actor, not a bounded context. Bounded contexts are drawn by ubiquitous language + capability, never by who logs in. Concretely:

1. Two capability contexts, two apps, one shared backend domain

The same real-world thing is described in two different languages:

  • Zelfbediening (SSP) — the Zorgverlener: "ik vraag herregistratie aan" — eligibility, fill in my data, upload documents, submit. apps/ssp in this repo (was "this repo" itself before WP-67 turned it into a monorepo).
  • Behandeling (backoffice) — the Behandelaar: "ik beoordeel de aanvraag" — werkvoorraad, beoordeling, besluit, meer-info-opvragen, SLA, audit. apps/behandelportal — a separate Angular project, not a separate repo (see the amendment below).

Diverging verbs over the same noun is the textbook signal for two bounded contexts.

2. The aggregate is owned by the backend; the contexts integrate through it

The aanvraag/registration is the system of record in the backend domain. Neither frontend owns it. They integrate through the backend using the BFF-lite decision DTOs of ADR-0001 — the same aggregate projected into two screen-shaped views. The aanvraag status lifecycle is the published contract between the two contexts:

Ingediend → In behandeling → (Meer info gevraagd ⇄) → Goedgekeurd / Afgewezen

The Behandeling context advances this lifecycle; the SSP reads it. WP-63 published the full lifecycle enum backend-side (AanvraagStatusTag); the SSP's dashboard pendingHerregistratie signal (big-profile.store.ts) turned out to be a pure client-side optimistic flag, not a read of any backend field — WP-65 is where a behandelaar action first reaches Ingediend/MeerInfoGevraagd.

graph TD
    subgraph FE["Frontend bounded contexts (two Angular projects, one repo — WP-67)"]
      SSP["<b>Zelfbediening (SSP)</b><br/>Zorgverlener · DigiD/BSN<br/><i>apps/ssp</i>"]
      BO["<b>Behandeling (backoffice)</b><br/>Behandelaar · employee SSO<br/><i>apps/behandelportal</i>"]
    end
    BE["<b>Backend domain</b><br/>aanvraag aggregate (system of record)<br/>status lifecycle · authorization"]
    SSP -- "reads aanvraag status<br/>(decision DTOs, ADR-0001)" --> BE
    BO -- "advances aanvraag status<br/>(decision DTOs, ADR-0001)" --> BE
    classDef c fill:#e5f1fb,stroke:#007bc7,color:#00567d;
    classDef d fill:#fff4e5,stroke:#e8830c,color:#8a4b00;
    class SSP,BO c;
    class BE d;

Both FE contexts are Customer/Conformist to the backend's published aanvraag model. This is deliberately not a Shared Kernel between the two apps — coupling two audiences' codebases directly would defeat the point of splitting them.

3. Separate identity from authorization

These are two concerns people habitually conflate; keeping them apart is the crux of the model.

  • Identity — "who are you, how did you log in" → the auth context. Model the principal as a discriminated union, the same "make illegal states unrepresentable" reflex as RemoteData:

    type Principal =
      | { kind: 'zorgverlener'; bsn: string; naam: string } // DigiD/BSN
      | { kind: 'medewerker'; medewerkerId: string; naam: string; rollen: Rol[] }; // employee SSO
    

    The union captures that the two actors authenticate differently and carry different identifiers — a Behandelaar has no BSN, a Zorgverlener has no rollen. This replaces the flat Session the day a second actor arrives.

  • Authorization — "what may you do" → enforced at the backend / context boundary, where the backend is the authority (per ADR-0001). It is not a permission matrix living in auth. The frontend receives only the decisions it needs to render (e.g. a canBeoordelen flag), exactly like every other server-owned rule.

4. "Other users" slot in without inventing contexts

Admin, auditor, institution-rep are additional Principal variants or additional rollen on medewerker — never a new folder-per-role. A genuinely new bounded context is warranted only when an actor brings a new language and capability (e.g. an "Toezicht/Handhaving" enforcement context), not merely a new login.

Consequences

  • apps/ssp stays the pure SSP. No backoffice code leaks in; no role-named folders appear.
  • The backoffice ships as a separate Angular project (apps/behandelportal, WP-67 — originally a separate repo, see the amendment below) against the same backend and the same OpenAPI contract.
  • The one concrete FE change when actor #2 lands is Session → Principal in the auth context; the authGuard/SessionStore seams already localise that (auth.guard.ts, session.store.ts).
  • The backend becomes the authority for the aanvraag status lifecycle and for authorization, publishing both as decision DTOs — a natural extension of ADR-0001, not a new pattern.
  • pendingHerregistratie is understood as a temporary stand-in for a real, backend-owned status.

Amendment (WP-67, 2026-08-01): one repo, not two

WP-61 initially built apps/behandelportal as a separate sibling repo (/home/eho/repos/behandelportal), taking this ADR's "separate frontend application" literally as "separate git repository." That produced real friction WP-67 then undid: a hand-vendored, manually-kept-in-sync copy of the backend's OpenAPI doc instead of a live-generated one, a shared/ui+shared/layout tree forked at WP-61 and already silently diverging by the time WP-67 checked (7 files), a beheer (admin/stamdata) context and styles.scss token bridge duplicated byte-for-byte across both repos, and a second CI/lint/CLAUDE.md to hand-maintain.

The bounded-context reasoning above is unchanged — it never depended on repo count. What changes is purely the packaging:

  • Two Angular CLI projects in one workspace: apps/ssp, apps/behandelportal — each still its own deployable, its own angular.json build/serve/test targets, its own port.
  • libs/shared (design system + kernel + the one generated API client) and libs/beheer (admin/stamdata — genuinely identical for both apps, not actor-specific) are cross-app libraries. auth stays duplicated, not unified — per §3 above, it's expected to diverge (Zorgverlener DigiD/BSN vs. Behandelaar employee SSO), so unifying it now would be forcing today's accidental similarity into a shape that fights tomorrow's real difference.
  • One backend, one OpenAPI doc, one generated client — the vendored-swagger workaround is gone; npm run gen:api regenerates the live doc straight into libs/shared.
  • Each app still needs its own Storybook instance (.storybook-ssp/, .storybook-behandelportal/) — @auth/* (and other context aliases) resolve to different physical directories per app, so one merged tsconfig can't serve both at once. This is a real, structural constraint, not a leftover of the old two-repo split.
  • The old sibling repo was left untouched (not deleted) when this migration landed — a redundant clone, safe to archive once the monorepo version is verified in daily use.

Out of scope here (next steps, not built)

  • Building the Behandeling backoffice application.
  • Real authentication: DigiD (SSP) and employee SSO / eHerkenning (backoffice).
  • The auth Session → Principal refactor — deferred until a second actor is actually introduced.
  • The backend aanvraag status lifecycle + authorization endpoints/DTOs.

ponytail: this ADR draws the boundaries so nothing has to be undone later; it does not scaffold a second app or a role system now. Introduce the Principal union and the status lifecycle when the backoffice work actually starts — YAGNI until then.