# ADR 0002 — User groups as actors, not bounded contexts Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67) ## 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](#amendment-wp-67-2026-08-01-one-repo-not-two) 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`. ```mermaid graph TD subgraph FE["Frontend bounded contexts (two Angular projects, one repo — WP-67)"] SSP["Zelfbediening (SSP)
Zorgverlener · DigiD/BSN
apps/ssp"] BO["Behandeling (backoffice)
Behandelaar · employee SSO
apps/behandelportal"] 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 - `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) - Real authentication: DigiD (SSP) and employee SSO / eHerkenning (backoffice). Two bullets that stood here — building the Behandeling backoffice, and the backend aanvraag status lifecycle + authorization endpoints/DTOs — **shipped** (WP-61…WP-67): `apps/behandelportal`, `AanvraagStatusTag` (`Domain/Applications/AanvraagStatus.cs`), `GET /me` (`Program.cs:578`), `Domain/Authorization/Authz.cs`. ## Known debt: `Session → Principal` was never built §3's `Principal` union is the one decision here that has **not** been executed, and it is now debt rather than a deferral. Actor #2 arrived — `apps/behandelportal` shipped — and the union did not follow. `grep -rn "Principal" apps libs` returns a single hit: a comment in `libs/shared/src/infrastructure/role.ts`. There is no such type. What that omission actually costs, measured 2026-08-26: - `apps/ssp/src/app/auth` and `apps/behandelportal/src/app/auth` are byte-identical — `diff -rq` reports **zero** content differences across 9 of 11 files, the only delta being two extra files in behandelportal. - `behandelportal`'s Behandelaar still carries a `bsn` and logs in through `DigidAdapter`. A backoffice user authenticates as a citizen, which is precisely what §3 was written to prevent. - The divergence that _did_ occur took an orthogonal side door — `medewerker.interceptor.ts`, a dev-only `X-Medewerker` header stamp that never touches `Session`. The WP-67 amendment above justifies keeping `auth` duplicated on the grounds that it is "expected to diverge". That reasoning still holds — but it has never been **tested**, because the change that would test it is this one. Read the two identical copies as evidence that §3 is unexecuted, not as evidence that §3 was wrong. ponytail: this ADR draws the boundaries so nothing has to be undone later. The original "YAGNI until the backoffice work starts" call was right when written and has now expired — the backoffice started. `Principal` is owed.