Files
atomic-design-poc/docs/architecture/0002-user-groups-and-bounded-contexts.md
Edwin van den Houdt e82309786d style: format frontend, docs and skills with prettier; add .prettierignore
One-time prettier --write so the new format:check CI gate starts green.
.prettierignore excludes generated (api-client.ts, documentation.json),
vendored (public/cibg-huisstijl), and backend (dotnet format owns it).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:39:31 +02:00

7.5 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 deployment.
  • 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. This repo.
  • Behandeling (backoffice) — the Behandelaar: "ik beoordeel de aanvraag" — werkvoorraad, beoordeling, besluit, meer-info-opvragen, SLA, audit. A sibling application, not a folder here.

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. Today the SSP already holds the seed of it — pendingHerregistratie in big-profile.store.ts:53 is the first, coarsest read of that status ("in behandeling"). As the backoffice appears, that single boolean grows into a real status the backend publishes.

graph TD
    subgraph FE["Frontend bounded contexts (separate apps)"]
      SSP["<b>Zelfbediening (SSP)</b><br/>Zorgverlener · DigiD/BSN<br/><i>this repo</i>"]
      BO["<b>Behandeling (backoffice)</b><br/>Behandelaar · employee SSO<br/><i>sibling app</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

  • This repo stays the pure SSP. No backoffice code leaks in; no role-named folders appear.
  • The backoffice ships as a separate app 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.

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.