From a2cd7a0ac1358da1e8d4261c35ce01dba7427d02 Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Wed, 1 Jul 2026 11:04:40 +0200 Subject: [PATCH] Add ADR 0002: user groups as actors, not bounded contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records how to model Zorgverlener (SSP), Behandelaar (backoffice), and future actors: personas are actors, not contexts; two capability contexts (Zelfbediening + Behandeling) as separate apps over one backend-owned aanvraag aggregate, integrating via ADR-0001 decision DTOs; identity (typed Principal union in auth) separated from authorization (backend-authoritative). Boundaries only — no code. Co-Authored-By: Claude Opus 4.8 --- .../0002-user-groups-and-bounded-contexts.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/architecture/0002-user-groups-and-bounded-contexts.md diff --git a/docs/architecture/0002-user-groups-and-bounded-contexts.md b/docs/architecture/0002-user-groups-and-bounded-contexts.md new file mode 100644 index 0000000..7f93bae --- /dev/null +++ b/docs/architecture/0002-user-groups-and-bounded-contexts.md @@ -0,0 +1,135 @@ +# 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. + +```mermaid +graph TD + subgraph FE["Frontend bounded contexts (separate apps)"] + SSP["Zelfbediening (SSP)
Zorgverlener · DigiD/BSN
this repo"] + BO["Behandeling (backoffice)
Behandelaar · employee SSO
sibling app"] + end + BE["Backend domain
aanvraag aggregate (system of record)
status lifecycle · authorization"] + SSP -- "reads aanvraag status
(decision DTOs, ADR-0001)" --> BE + BO -- "advances aanvraag status
(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`: + + ```ts + 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.