Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
171 lines
10 KiB
Markdown
171 lines
10 KiB
Markdown
# 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 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)
|
|
|
|
- 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.
|