Files
atomic-design-poc/docs/reference/architecture/0002-user-groups-and-bounded-contexts.md
T
ehoandClaude Opus 5 4debf6614f docs(adr-0002): accept, and record the unbuilt Principal union as debt
ADR-C-005 from the ADR-conformance pass.

Status Proposed -> Accepted. Two apps have shipped against this ADR and its
structural rulings run in CI at severity: error with 0 violations; the other
five ADRs are all Accepted. A decision CI enforces is not "Proposed".

Drops two "out of scope, not built" bullets that have since shipped
(WP-61..67) — the Behandeling backoffice, and the backend status lifecycle +
authz DTOs: AanvraagStatusTag, GET /me (Program.cs:578), Domain/
Authorization/Authz.cs. Real authentication is the one that genuinely stays.

Replaces the `Session -> Principal` deferral with a Known debt section. The
deferral was conditional on the backoffice not existing yet; it does now, and
the union did not follow. `grep -rn "Principal" apps libs` returns one hit,
a comment. Consequently the two auth contexts are byte-identical (diff -rq:
zero content differences), and behandelportal's Behandelaar still carries a
bsn and logs in through DigiD — a backoffice user authenticating as a
citizen, which is what §3 was written to prevent. The divergence that did
happen took an orthogonal side door (medewerker.interceptor.ts) that never
touches Session.

The section says explicitly that the WP-67 amendment's "expected to diverge"
reasoning still holds but has never been tested, so the identical copies are
evidence §3 is unexecuted — not evidence §3 was wrong. Without that, a future
reader is likely to "simplify" the duplication away and cement the citizen
login into the backoffice.

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

11 KiB

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 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)

  • 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.