Files
atomic-design-poc/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md
ehoandClaude Opus 5 f19185ed81 refactor(auth): land Session -> Principal, add MedewerkerAdapter (RB-13)
ADR-0002 SS3 models Zorgverlener/Medewerker as different Principal
variants with different login flows. Actor #2 (apps/behandelportal)
landed in WP-61/67 and the union never followed: grep -rn "Principal"
returned one hit, a comment. Both apps' auth/domain/session.ts stayed
byte-identical (`{ bsn, naam }`), so the backoffice's Behandelaar
carried a BSN and logged into the backoffice as a citizen, by DigiD,
under a fabricated citizen's name (login.page.ts). The divergence
ADR-0002 predicted took an orthogonal side door instead
(medewerker.interceptor.ts's X-Medewerker/X-Rollen stamp, which never
touches SessionStore) -- which is why ssp/auth and bhp/auth still
measured as 100%/84% duplicated after ADR-C-006 shared the route
guards. RB-09 (landed the day before) made the backend's
IIdentityProvider able to say "no identity" and fail closed; this
ticket is its named FE half.

Each app's auth/domain/session.ts becomes principal.ts, holding the
one Principal variant that app actually has an actor for: ssp keeps
`{ kind: 'zorgverlener', bsn, naam }` (G1 still strips the BSN before
persisting); behandelportal gets `{ kind: 'medewerker', medewerkerId,
naam, rollen }` (no BSN to strip -- G2 shape validation only). A new
MedewerkerAdapter replaces DigidAdapter in behandelportal, resolving
the existing MEDEWERKER_ID/currentRollen() dev stand-in into a
Principal; because there is no credential to check, it returns
Principal directly rather than a Result whose error variant could
never occur. login.page.ts stops being a BSN/wachtwoord form -- one
explainer line and an "Inloggen met SSO" button -- and its dead
error-handling branch goes with the Result wrapper that justified it.

Measured with tools/baseline-scan.mjs --dup: auth duplication drops
from 168/168 (ssp) and 168/200 (bhp) to 32/179 and 32/259 -- under the
backlog's <40 target. What remains is the ADR-C-006 route-guard
re-export (deliberately identical), generic test/story-file
boilerplate, and one shared fragment of the root-singleton-store
idiom -- not re-converged identity or login-flow logic. SS3's
prediction that the two actors would authenticate differently enough
to justify not sharing auth has now actually been tested, not just
asserted, and held.

Also: renamed Session.bsn to Principal.bsn in two doc comments
(libs/shared/src/infrastructure/subject.ts, subject.interceptor.ts)
that cited the old type name; regenerated
libs/shared/docs/behaviour-spec.mdx (generated file, per its own
banner); recorded the resolution in ADR-0002 as a new amendment,
replacing its "Known debt" section.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:54:26 +02:00

201 lines
12 KiB
Markdown

# ADR 0002 — User groups as actors, not bounded contexts
Status: Accepted · Date: 2026-07-01 · Amended 2026-08-01 (WP-67), 2026-08-27 (RB-13)
## 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["<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`:
```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`.
A third bullet stood here too — `Session → Principal` — from 2026-08-26 until it was paid
off by RB-13 the next day. See the amendment below for the historical record and what
landed.
## Amendment (RB-13, 2026-08-27): `Session → Principal` landed
§3's `Principal` union was accepted on 2026-07-01 and not executed until now — see the
"Known debt" record this replaces, added 2026-08-26 by the refactor-backlog audit
(`ADR-C-004`) that found it. `apps/ssp/src/app/auth/domain/principal.ts` now exports the
`zorgverlener` variant (`{ kind: 'zorgverlener'; bsn; naam }`);
`apps/behandelportal/src/app/auth/domain/principal.ts` exports the `medewerker` variant
(`{ kind: 'medewerker'; medewerkerId; naam; rollen }`) — each app holds only the one
member of the union it actually has an actor for, per this ADR's own proposed resolution.
`apps/behandelportal`'s `DigidAdapter` is gone; a `MedewerkerAdapter` resolves the
dev-stand-in medewerker identity (`medewerker.ts`'s `MEDEWERKER_ID`/`currentRollen()` —
unchanged, still the mechanism `medewerkerInterceptor` uses for the backend headers) into
a `Principal` instead, and `login.page.ts` is an SSO-stand-in entry (one button, no BSN
field) rather than the citizen DigiD form it used to share with the SSP verbatim.
The two `auth` contexts, measured 2026-08-27 after the change
(`tools/baseline-scan.mjs --dup`): **32 duplicated lines each** (from 168 at the
2026-08-26 measurement above; from 211 before ADR-C-006 shared the route guards). What
remains is not re-converged identity/login-flow code — it is `auth.guard.ts`'s intentional
verbatim re-export (ADR-C-006: a route guard is actor-agnostic, not in this ADR's scope)
plus ordinary test/story-file boilerplate (`describe`/`it` shape, a `Meta`/`StoryObj`
scaffold) that any two spec or story files share regardless of subject. The prediction in
§3 — that Zorgverlener and Medewerker, modelled as distinct `Principal` variants, would
turn out to authenticate differently enough that sharing `auth` would have been the wrong
call — has now actually been tested, not just asserted, and held: the two contexts diverge
in domain type, adapter, and login UI as soon as the union exists to make that
divergence possible.