feat(WP-67): merge behandelportal into this repo as a monorepo

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>
This commit is contained in:
eho
2026-08-02 21:01:57 +02:00
co-authored by Claude Sonnet 5
parent d3f3b13345
commit e7156c5132
403 changed files with 7103 additions and 60917 deletions
@@ -21,7 +21,9 @@ Getting this wrong is expensive: split the code by role and every feature smears
Confirmed constraints (with the product owner):
- The backoffice is a **separate frontend application**, own audience, own deployment.
- 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.
@@ -43,9 +45,11 @@ Confirmed constraints (with the product owner):
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.**
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. **A sibling application**, not a folder here.
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**.
@@ -60,16 +64,16 @@ 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.
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 (separate apps)"]
SSP["<b>Zelfbediening (SSP)</b><br/>Zorgverlener · DigiD/BSN<br/><i>this repo</i>"]
BO["<b>Behandeling (backoffice)</b><br/>Behandelaar · employee SSO<br/><i>sibling app</i>"]
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
@@ -115,14 +119,45 @@ 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.
- `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.
+59 -43
View File
@@ -16,24 +16,33 @@ re-registration — "herregistratie").
---
## 1. The big picture: six "contexts", five "layers"
## 1. The big picture: two apps, cross-app libraries, "contexts", "layers"
The code is split first by **business area** (a "bounded context" in DDD terms),
then inside each area by **layer**.
Two Angular projects share one backend and two cross-app libraries (WP-67 — see
ADR-0002's amendment for why this is a monorepo, not two repos). Inside each app (and
each library), the code is split first by **business area** (a "bounded context" in DDD
terms), then inside each area by **layer**.
```
src/app/
shared/ things every context reuses (no business logic of its own)
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every context)
apps/
ssp/src/app/ Zorgverlener self-service (ADR-0002) — this doc's main subject
auth/ logging in / the current session
registratie/ the user's BIG registration + personal data
herregistratie/ the re-registration application flow
brief/ letter-composition teaching slice
showcase/ a teaching page; not a real feature (may read every ssp context)
behandelportal/src/app/ Behandelaar backoffice (ADR-0002) — a sibling app, not covered here
auth/ its own login (employee SSO, not DigiD/BSN)
behandeling/ werkvoorraad, beoordeling (WP-64/65)
libs/
shared/src/ things every app reuses (no business logic of its own)
beheer/src/ admin/stamdata — a real bounded context, used identically by both apps
```
`showcase/` is a **sanctioned exception** to the direction rules: its whole point is
showing multiple contexts side by side, so it may import any context. Nothing imports
`showcase`. (Enforced in `eslint.config.mjs`; same precedent as the `debug-state` panel.)
`showcase/` is a **sanctioned exception** to the direction rules, scoped to `apps/ssp`:
its whole point is showing multiple ssp contexts side by side, so it may import any of
them. Nothing imports `showcase`. (Enforced per-app in `.dependency-cruiser.<app>.js`;
same precedent as the `debug-state` panel — see below.)
### The atomic-design hierarchy, visualised
@@ -70,25 +79,32 @@ questions:
| `ui/` | How does it look? | Yes (components) | `dashboard.page.ts` |
**The one rule that keeps it sane: dependencies only point _inward_.** UI may use
application, application may use domain, everyone may use `shared`. Never the
other way around. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced).
application, application may use domain, everyone (in either app) may use `libs/shared`
and `libs/beheer`. Never the other way around — `libs/shared` may not depend on
`libs/beheer` either (it stays the base), and an app may not import the other app's
source. In particular **`ui/` and `layout/` never import `infrastructure/`
directly** — they reach data through an application store or command (lint-enforced,
per app — a single merged tsconfig can't resolve both apps' `@auth/*` alias at once, so
each app is cruised separately against its own `.dependency-cruiser.<app>.js`).
The `domain/` layer imports nothing from Angular, so the business rules are plain
functions you can read and test in isolation.
Allowed direction: `herregistratie → registratie → shared`, `auth → shared`,
`brief → shared` (`showcase` may read every context; see above).
Allowed direction (within `apps/ssp`): `herregistratie → registratie → libs/shared|beheer`,
`auth → libs/shared|beheer`, `brief → libs/shared|beheer` (`showcase` may read every ssp
context; see above). `apps/behandelportal` has its own analogous rule for `behandeling`/`auth`.
### Why the `shared/` kernel is split too
### Why `libs/shared` is split into layers too
- `shared/kernel/` — tiny generic helpers (no Angular).
- `shared/application/` — generic state tools (RemoteData, the store).
- `shared/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
- `shared/layout/` — page chrome (header, footer, shells).
- `shared/infrastructure/` — the demo HTTP interceptor.
- `libs/shared/src/kernel/` — tiny generic helpers (no Angular).
- `libs/shared/src/application/` — generic state tools (RemoteData, the store).
- `libs/shared/src/ui/` — the atomic-design building blocks (buttons, inputs, the async renderer). These know nothing about BIG-register.
- `libs/shared/src/layout/` — page chrome (header, footer, shells) — takes each app's own nav/copy via `input()`s or an injection token rather than hardcoding one app's content.
- `libs/shared/src/infrastructure/` — the demo HTTP interceptor + the one generated API client both apps import.
Imports use path aliases so they read as direction statements:
`@shared/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`.
`@shared/*`, `@beheer/*`, `@auth/*`, `@registratie/*`, `@herregistratie/*`, `@brief/*`
(ssp) — `@shared/*`, `@beheer/*`, `@auth/*`, `@behandeling/*` (behandelportal); each
app's own `tsconfig.json` declares its full alias map.
---
@@ -150,7 +166,7 @@ Three signals = eight combinations, and most are nonsense (loading **and** has
data **and** has an error?). You end up writing defensive `if`s everywhere.
Instead we use **one** value that is _exactly one of_ four shapes
(`shared/application/remote-data.ts`):
(`libs/shared/src/application/remote-data.ts`):
```ts
type RemoteData<E, T> =
@@ -166,7 +182,7 @@ Notice the data lives _on_ the shape — you literally cannot read `.value` unle
you're in the `Success` case, so "loaded but no data" can't be written down.
To use it, you handle every case once. The `<app-async>` component
(`shared/ui/async/async.component.ts`) does this for you: you give it a
(`libs/shared/src/ui/async/async.component.ts`) does this for you: you give it a
`RemoteData` (or a raw `httpResource`) and four templates, and it shows exactly
one. There's also `foldRemote(rd, { loading, empty, failure, success })` for
doing the same in TypeScript — the compiler makes you cover all four.
@@ -277,7 +293,7 @@ function reduce(state, msg) {
```
The component (`herregistratie-wizard.component.ts`) wires it to a signal with
the tiny helper in `shared/application/store.ts`:
the tiny helper in `libs/shared/src/application/store.ts`:
```ts
private store = createStore(initial, reduce);
@@ -341,7 +357,7 @@ A common assumption is "the form saves on blur." It doesn't. **Blur only marks a
_touched_** so validation can show; it never writes the value or hits the network. In the
shared atoms, `(blur)="onTouched()"` is the `ControlValueAccessor` touched callback and
nothing more; the value is pushed on `(input)`, every keystroke
([`text-input.component.ts`](../../../src/app/shared/ui/text-input/text-input.component.ts):
([`text-input.component.ts`](../../../libs/shared/src/ui/text-input/text-input.component.ts):
`(input)` L29 → `onChange` L62, vs `(blur)="onTouched()"` L30).
The real flow has two stages, neither keyed on focus:
@@ -349,20 +365,20 @@ The real flow has two stages, neither keyed on focus:
1. **Keystroke → Model.** A field binds `(ngModelChange)`/`(input)` and dispatches
`{ tag: 'SetField', key, value }`. The pure reducer stores it immediately — so the
Model is always current, on every keystroke, while editing.
([`herregistratie-wizard.component.ts`](../../../src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
L78 → [`herregistratie.machine.ts`](../../../src/app/herregistratie/domain/herregistratie.machine.ts)
([`herregistratie-wizard.component.ts`](../../../apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts)
L78 → [`herregistratie.machine.ts`](../../../apps/ssp/src/app/herregistratie/domain/herregistratie.machine.ts)
L138-142, `setField`.)
2. **Model → backend (600 ms debounce).** A signal `effect` tracks the machine
`snapshot()`; each change resets a 600 ms timer whose callback does I/O **only** (it
never dispatches, so it can't livelock the store). On the first save it lazily creates
the application and stamps `?aanvraag=<id>` into the URL, so a reload resumes the draft.
([`draft-sync.ts`](../../../src/app/registratie/application/draft-sync.ts):
([`draft-sync.ts`](../../../apps/ssp/src/app/registratie/application/draft-sync.ts):
`DEBOUNCE_MS` L34, `effect` L102-108, `flush` L88-98 → `ApplicationsAdapter.syncDraft`.)
The **brief** context uses the same 600 ms idiom in its own store: `edit()` applies the
edit optimistically in the reducer and records an undo step, then `scheduleSave()`
`flushSave()` flips a `saveState` (Saving/Saved/Error) and calls `adapter.save`
([`brief.store.ts`](../../../src/app/brief/application/brief.store.ts) L157-166, L192-209).
([`brief.store.ts`](../../../apps/ssp/src/app/brief/application/brief.store.ts) L157-166, L192-209).
So it _feels_ like save-on-blur only because you usually stop typing when you leave a
field, and the debounce fires ~600 ms later. The trigger is **"stopped changing," not
@@ -370,7 +386,7 @@ field, and the debounce fires ~600 ms later. The trigger is **"stopped changing,
**The last-mile guard (leaving mid-debounce).** A debounce means an edit made in the final
&lt;600 ms before you leave hasn't been written yet. Two seams close that window
([`pending-saves.ts`](../../../src/app/shared/application/pending-saves.ts)): every autosave
([`pending-saves.ts`](../../../libs/shared/src/application/pending-saves.ts)): every autosave
owner (the brief/org-template root stores and each wizard's `draft-sync`) registers in a
`PendingSaves` registry, and
@@ -418,7 +434,7 @@ baked into the type. Same idea for `Uren` and `BigNummer`.
3. **Application.** If there's state to coordinate, add/extend a store
(`providedIn: 'root'` if it must be shared across pages). Model state as a
discriminated union; change it only through a pure `update`/`reduce`.
4. **UI last.** Build the page/organism from `shared/ui` atoms. Render async
4. **UI last.** Build the page/organism from `libs/shared/ui` atoms. Render async
state through `<app-async>`. Send messages; don't mutate.
If you're tempted to add a third boolean to track state — stop and model it as a
@@ -527,7 +543,7 @@ Practical notes, kept lazy:
The sketch above is the _rationale_; the shipped shape has since firmed up. The contract is
no longer hand-written DTOs — it's an **NSwag-generated typed client**
([`api-client.ts`](../../../src/app/shared/infrastructure/api-client.ts), regenerate with
([`api-client.ts`](../../../libs/shared/src/infrastructure/api-client.ts), regenerate with
`npm run gen:api` per [`nswag.json`](../../../nswag.json)) — and the boundary is a
`parse*` returning `Result` rather than `httpResource({ parse })`. End to end:
@@ -541,25 +557,25 @@ no longer hand-written DTOs — it's an **NSwag-generated typed client**
`HttpClient` by `httpClientFetch` — the one place cross-cutting concerns live:
`X-Correlation-Id` on every call, `Idempotency-Key` on non-GETs, a 10 s timeout, and
GET-only retry. Routing through `HttpClient` is exactly what lets the interceptors see API
traffic. ([`api-client.provider.ts`](../../../src/app/shared/infrastructure/api-client.provider.ts):
traffic. ([`api-client.provider.ts`](../../../libs/shared/src/infrastructure/api-client.provider.ts):
`httpClientFetch` L47-82, `provideApiClient` L86-92; registered in
[`app.config.ts`](../../../src/app/app.config.ts) L37.)
[`app.config.ts`](../../../apps/ssp/src/app/app.config.ts) L37.)
- **Interceptors (dev-only, stripped in prod).** `scenario.interceptor.ts` (the `?scenario=`
toggle) and `role.interceptor.ts` (`X-Role` on role-aware endpoints).
**A read (dashboard):** `<app-async [data]="store.profile()">`
[`BigProfileStore`](../../../src/app/registratie/application/big-profile.store.ts) →
[`BigProfileStore`](../../../apps/ssp/src/app/registratie/application/big-profile.store.ts) →
`DashboardViewAdapter.dashboardViewResource()` = `resource({ loader: () =>
client.dashboardView() })`
([`dashboard-view.adapter.ts`](../../../src/app/registratie/infrastructure/dashboard-view.adapter.ts))
([`dashboard-view.adapter.ts`](../../../apps/ssp/src/app/registratie/infrastructure/dashboard-view.adapter.ts))
→ GET `/api/v1/dashboard-view``httpClientFetch` → proxy → backend → back through the
`parseDashboardView(json): Result` trust boundary → `RemoteData<DashboardView>` → rendered.
**A write (change address):** `runIfSubmitting()` (§2d) → `createSubmitChangeRequest`
([`submit-change-request.ts`](../../../src/app/registratie/application/submit-change-request.ts))
([`submit-change-request.ts`](../../../apps/ssp/src/app/registratie/application/submit-change-request.ts))
`runSubmit` — the one try/catch that mints the `Idempotency-Key` and maps RFC-7807
ProblemDetails → string ([`submit.ts`](../../../src/app/shared/application/submit.ts)) →
[`change-request.adapter.ts`](../../../src/app/registratie/infrastructure/change-request.adapter.ts)
ProblemDetails → string ([`submit.ts`](../../../libs/shared/src/application/submit.ts)) →
[`change-request.adapter.ts`](../../../apps/ssp/src/app/registratie/infrastructure/change-request.adapter.ts)
→ POST `/api/v1/change-requests``ok(referentie)` / `err(detail)` → dispatch
`SubmitConfirmed` / `SubmitFailed`.
+41 -23
View File
@@ -1,35 +1,50 @@
# Dependencies & boundaries
How the app's **bounded-context** and **atomic-layer** boundaries are declared, enforced, and
visualized (WP-38). One declarative source — `.dependency-cruiser.js` — both **guards** the edges
and **draws** the graph, replacing the per-context `no-restricted-imports` blocks that previously
had to be hand-copied (and that had left `herregistratie` uncovered).
visualized (WP-38, generalized for the two-app monorepo by WP-67). One declarative source —
`.dependency-cruiser.base.js` (a rule _factory_) plus one thin `.dependency-cruiser.<app>.js` per
app — both **guards** the edges and **draws** the graph, replacing the per-context
`no-restricted-imports` blocks that previously had to be hand-copied (and that had left
`herregistratie` uncovered). Each app is cruised **separately**, against its own `tsconfig.json`:
a single merged tsconfig can't resolve both apps' `@auth/*` alias at once (each points at a
different physical directory), so there is no single global config file.
## The rules (single source: `.dependency-cruiser.js`)
## The rules (single source: `.dependency-cruiser.base.js`)
**Bounded-context direction** — dependencies point inward; everyone may use `shared`, nothing
imports `showcase`:
**Bounded-context direction** — dependencies point inward; every app context may use
`libs/shared` and `libs/beheer`, nothing imports `showcase`:
| Context | May import |
| ---------------- | ------------------------------------- |
| `shared` | (base — no feature context) |
| `auth` | `shared` |
| `registratie` | `shared` |
| `herregistratie` | `registratie`, `shared` |
| `brief` | `shared` |
| `beheer` | `shared` |
| `showcase` | everything (sanctioned teaching page) |
| Context (apps/ssp) | May import |
| ------------------ | --------------------------------------------------- |
| `auth` | `libs/shared`, `libs/beheer` |
| `registratie` | `libs/shared`, `libs/beheer` |
| `herregistratie` | `registratie`, `libs/shared`, `libs/beheer` |
| `brief` | `libs/shared`, `libs/beheer` |
| `showcase` | everything in `apps/ssp` (sanctioned teaching page) |
| Context (apps/behandelportal) | May import |
| ----------------------------- | ---------------------------- |
| `auth` | `libs/shared`, `libs/beheer` |
| `behandeling` | `libs/shared`, `libs/beheer` |
`libs/shared` is the base (no feature context, no `libs/beheer` — that direction is forbidden too,
so shared never grows a dependency on a peer library). `libs/beheer` is a real bounded context
(admin/stamdata), used identically by both apps — it may depend on `libs/shared`, never the
reverse. An app may not import the other app's source directly.
**Atomic-layer rules:** `domain/` is framework-free (no Angular); `contracts/` import nothing
(pure wire DTOs, ADR-0001); `ui/` + `layout/` never import `infrastructure/` directly (reach data
through an application store/command — type-only DTO imports are fine); the generated `ApiClient`
is a value only inside `infrastructure/` (+ `shared/upload`). Plus **no circular** dependencies.
Sanctioned exceptions: `shared/ui/debug-state` (dev panel) and `showcase`.
is a value only inside `infrastructure/` (+ `libs/shared/src/upload`). Plus **no circular**
dependencies. These apply uniformly across an app's tree and both libraries — no debug-state
exception anymore (WP-67 moved the dev panel component out of `libs/shared` into `apps/ssp` since
it's genuinely SSP-specific, coupled to `BigProfileStore`; the shared `ShellComponent` hosts
whichever app-provided component the `DEBUG_PANEL` injection token supplies, or none).
## See the graph
```bash
npm run dep:graph # regenerates docs/reference/architecture/dependency-graph.md (mermaid)
npm run dep:graph # regenerates docs/reference/architecture/dependency-graph.md (mermaid) — one diagram per app
```
[dependency-graph.md](./dependency-graph.md) is the generated, committed view — contexts × atomic
@@ -39,7 +54,7 @@ change.
## Enforce
```bash
npm run dep:check # fails on any forbidden edge; part of `npm run ci` and CI
npm run dep:check # runs both apps' configs; fails on any forbidden edge; part of `npm run ci` and CI
```
A violation prints the offending `from → to` and the rule name. `dep:check` runs in the local gate
@@ -48,10 +63,13 @@ A violation prints the offending `from → to` and the rule name. `dep:check` ru
## What still lives in ESLint
Only the non-dependency rules: `@typescript-eslint/no-explicit-any` and the angular-eslint template
accessibility bundle (see `eslint.config.mjs`). Everything about _who may import whom_ is in
dependency-cruiser.
accessibility bundle (see `eslint.config.mjs`, scoped to `{apps,libs}/**`). Everything about _who
may import whom_ is in dependency-cruiser.
## Adding a context
Add one `contextRule(...)` entry in `.dependency-cruiser.js` (and the tsconfig path alias + lazy
route) — no more hand-copying ESLint blocks. The `new-context` skill covers the full checklist.
Add one context entry to the relevant app's object literal in `.dependency-cruiser.ssp.js` (or
`.dependency-cruiser.behandelportal.js`) — passed straight into the shared `buildConfig` factory in
`.dependency-cruiser.base.js` (and the tsconfig path alias + lazy route) — no more hand-copying
ESLint blocks. The `new-context` skill / `gen:context` (`plopfile.mjs`, ssp-only today) covers the
full checklist.
+495 -174
View File
@@ -1,195 +1,516 @@
# Dependency graph
_Generated by `npm run dep:graph` — do not edit by hand._ Nodes are context × atomic
layer (`src/app/<context>/<layer>`); edges are real imports. The allowed edges are
enforced by `npm run dep:check` (dependency-cruiser); see [dependencies.md](./dependencies.md).
layer (`apps/<app>/src/app/<context>/<layer>` or `libs/<lib>/src/<layer>`); edges are
real imports. The allowed edges are enforced by `npm run dep:check` (dependency-cruiser,
one config per app — see [dependencies.md](./dependencies.md)).
## ssp
```mermaid
flowchart LR
subgraph 0["src"]
subgraph 1["app"]
2["app.config.ts"]
3["app.routes.ts"]
4["app.ts"]
subgraph 5["auth"]
6["application"]
7["auth.guard.spec.ts"]
8["auth.guard.ts"]
9["domain"]
A["infrastructure"]
B["ui"]
subgraph 0["apps"]
subgraph 1["ssp"]
subgraph 2["src"]
subgraph 3["app"]
4["app.config.ts"]
5["app.routes.ts"]
6["app.ts"]
7["auth"]
8["brief"]
9["herregistratie"]
A["registratie"]
B["shell"]
C["showcase"]
end
subgraph C["beheer"]
D["application"]
E["contracts"]
F["domain"]
G["infrastructure"]
H["ui"]
D["main.ts"]
end
subgraph I["brief"]
J["application"]
K["domain"]
L["infrastructure"]
end
end
subgraph E["libs"]
subgraph F["beheer"]
subgraph G["src"]
H["application"]
I["contracts"]
J["domain"]
K["infrastructure"]
L["test-entry.ts"]
M["ui"]
end
subgraph N["herregistratie"]
O["application"]
P["domain"]
Q["infrastructure"]
R["ui"]
end
subgraph S["registratie"]
T["application"]
U["contracts"]
V["domain"]
W["infrastructure"]
X["ui"]
end
subgraph Y["shared"]
Z["application"]
10["domain"]
11["infrastructure"]
12["kernel"]
13["layout"]
14["ui"]
15["upload"]
end
subgraph 16["showcase"]
17["concepts.page.ts"]
18["highlight-ts.spec.ts"]
19["highlight-ts.ts"]
1A["snippets.generated.ts"]
subgraph N["shared"]
subgraph O["src"]
P["application"]
Q["domain"]
R["environments"]
S["infrastructure"]
T["kernel"]
U["layout"]
V["test-entry.ts"]
W["ui"]
X["upload"]
end
end
end
2-->3
2-->6
2-->Z
2-->11
2-->13
3-->17
3-->8
3-->B
3-->H
3-->M
3-->R
3-->X
3-->Z
3-->13
6-->9
6-->A
6-->12
7-->6
7-->8
7-->Z
8-->6
8-->Z
8-->10
A-->9
subgraph Y["node_modules"]
subgraph Z["@angular"]
subgraph 10["common"]
subgraph 11["fesm2022"]
12["common.mjs"]
13["http.mjs"]
end
subgraph 14["locales"]
15["en.js"]
16["nl.js"]
end
end
subgraph 17["core"]
subgraph 18["fesm2022"]
19["core.mjs"]
1A["rxjs-interop.mjs"]
1B["testing.mjs"]
end
end
subgraph 1C["forms"]
subgraph 1D["fesm2022"]
1E["forms.mjs"]
end
end
subgraph 1F["localize"]
subgraph 1G["fesm2022"]
1H["localize.mjs"]
end
end
subgraph 1I["platform-browser"]
subgraph 1J["fesm2022"]
1K["platform-browser.mjs"]
end
end
subgraph 1L["router"]
subgraph 1M["fesm2022"]
1N["router.mjs"]
end
end
end
subgraph 1O["@storybook"]
subgraph 1P["angular"]
subgraph 1Q["dist"]
1R["index.js"]
end
end
end
subgraph 1S["rxjs"]
subgraph 1T["dist"]
subgraph 1U["cjs"]
1V["index.js"]
subgraph 1W["operators"]
1X["index.js"]
end
end
end
end
subgraph 1Y["storybook"]
subgraph 1Z["dist"]
subgraph 20["test"]
21["index.js"]
end
end
end
subgraph 22["vitest"]
subgraph 23["dist"]
24["index.js"]
end
end
end
4-->5
4-->B
4-->12
4-->13
4-->15
4-->16
4-->19
4-->1N
4-->7
4-->P
4-->S
4-->U
5-->C
5-->1N
5-->7
5-->M
5-->8
5-->9
5-->A
5-->P
5-->U
6-->19
6-->1N
7-->19
7-->1N
7-->P
7-->Q
7-->T
7-->U
7-->W
7-->1E
7-->1B
7-->24
7-->1R
8-->19
8-->U
8-->W
8-->P
8-->T
8-->X
8-->S
8-->R
8-->1E
8-->12
8-->1B
8-->24
8-->1R
9-->19
9-->1E
9-->A
9-->P
9-->T
9-->U
9-->W
9-->X
9-->S
9-->24
9-->13
9-->1R
9-->1B
A-->T
A-->19
A-->P
A-->S
A-->1N
A-->12
B-->6
B-->13
B-->14
D-->F
D-->G
D-->Z
D-->12
F-->12
G-->F
G-->11
G-->12
G-->Z
H-->D
H-->Z
H-->13
H-->14
H-->F
J-->K
J-->L
J-->Z
J-->12
J-->15
K-->12
K-->15
L-->K
L-->Z
L-->11
L-->12
M-->J
M-->13
M-->14
M-->K
A-->W
A-->U
A-->B
A-->Q
A-->1E
A-->X
A-->1B
A-->24
A-->1R
A-->13
B-->Q
B-->U
B-->12
B-->19
B-->7
B-->A
B-->P
B-->S
B-->T
B-->1R
C-->19
C-->1E
C-->9
C-->A
C-->T
C-->U
C-->W
C-->24
D-->6
D-->4
D-->1H
D-->1K
H-->19
H-->J
H-->K
H-->P
H-->1B
H-->T
H-->24
J-->T
J-->24
K-->19
K-->J
K-->S
K-->T
K-->P
K-->24
M-->12
M-->Z
M-->15
O-->P
O-->Q
P-->V
P-->12
P-->15
Q-->P
Q-->11
Q-->12
R-->P
M-->19
M-->H
M-->P
M-->U
M-->W
M-->J
M-->1R
P-->19
P-->T
P-->1N
P-->S
P-->1A
P-->Q
P-->1V
P-->24
P-->1B
S-->13
S-->19
S-->R
S-->1V
S-->Q
S-->T
S-->1X
S-->24
T-->24
U-->19
U-->1E
U-->W
U-->Q
U-->12
U-->1N
U-->1A
U-->1V
U-->P
U-->1X
U-->1R
U-->24
W-->19
W-->1E
W-->X
W-->12
W-->1N
W-->P
W-->T
W-->1R
W-->21
W-->24
X-->T
X-->19
X-->P
X-->S
X-->R
X-->24
```
## behandelportal
```mermaid
flowchart LR
subgraph 0["apps"]
subgraph 1["behandelportal"]
subgraph 2["src"]
subgraph 3["app"]
4["app.config.ts"]
5["app.routes.ts"]
6["app.ts"]
7["auth"]
8["behandeling"]
9["shell"]
end
A["main.ts"]
end
end
end
subgraph B["libs"]
subgraph C["beheer"]
subgraph D["src"]
E["application"]
F["contracts"]
G["domain"]
H["infrastructure"]
I["test-entry.ts"]
J["ui"]
end
end
subgraph K["shared"]
subgraph L["src"]
M["application"]
N["domain"]
O["environments"]
P["infrastructure"]
Q["kernel"]
R["layout"]
S["test-entry.ts"]
T["ui"]
U["upload"]
end
end
end
subgraph V["node_modules"]
subgraph W["@angular"]
subgraph X["common"]
subgraph Y["fesm2022"]
Z["common.mjs"]
10["http.mjs"]
end
subgraph 11["locales"]
12["en.js"]
13["nl.js"]
end
end
subgraph 14["core"]
subgraph 15["fesm2022"]
16["core.mjs"]
17["rxjs-interop.mjs"]
18["testing.mjs"]
end
end
subgraph 19["forms"]
subgraph 1A["fesm2022"]
1B["forms.mjs"]
end
end
subgraph 1C["localize"]
subgraph 1D["fesm2022"]
1E["localize.mjs"]
end
end
subgraph 1F["platform-browser"]
subgraph 1G["fesm2022"]
1H["platform-browser.mjs"]
end
end
subgraph 1I["router"]
subgraph 1J["fesm2022"]
1K["router.mjs"]
end
end
end
subgraph 1L["@storybook"]
subgraph 1M["angular"]
subgraph 1N["dist"]
1O["index.js"]
end
end
end
subgraph 1P["rxjs"]
subgraph 1Q["dist"]
subgraph 1R["cjs"]
1S["index.js"]
subgraph 1T["operators"]
1U["index.js"]
end
end
end
end
subgraph 1V["storybook"]
subgraph 1W["dist"]
subgraph 1X["test"]
1Y["index.js"]
end
end
end
subgraph 1Z["vitest"]
subgraph 20["dist"]
21["index.js"]
end
end
end
4-->5
4-->9
4-->Z
4-->10
4-->12
4-->13
4-->16
4-->1K
4-->7
4-->M
4-->P
4-->R
5-->1K
5-->7
5-->8
5-->J
5-->R
6-->16
6-->1K
7-->16
7-->1K
7-->M
7-->N
7-->Q
7-->R
7-->T
7-->1B
7-->18
7-->21
7-->1O
8-->16
8-->R
9-->R
A-->6
A-->4
A-->1E
A-->1H
E-->16
E-->G
E-->H
E-->M
E-->18
E-->Q
E-->21
G-->Q
G-->21
H-->16
H-->G
H-->P
H-->Q
H-->M
H-->21
J-->Z
J-->16
J-->E
J-->M
J-->R
J-->T
J-->G
J-->1O
M-->16
M-->17
M-->N
M-->P
M-->1S
M-->Q
M-->1K
M-->21
M-->18
P-->16
P-->N
P-->Q
P-->10
P-->O
P-->1S
P-->1U
P-->21
Q-->21
R-->16
R-->T
R-->Z
R-->12
R-->13
R-->14
R-->15
R-->O
R-->V
R-->11
T-->U
T-->V
T-->W
R-->1K
R-->17
R-->1S
R-->M
R-->1U
R-->N
R-->1O
R-->21
R-->1B
T-->16
T-->1B
T-->Z
T-->11
T-->12
V-->12
V-->15
W-->V
W-->11
W-->12
W-->U
X-->V
X-->14
X-->T
X-->13
X-->Z
X-->12
X-->10
X-->U
X-->15
X-->11
Z-->12
Z-->11
Z-->10
11-->10
11-->12
13-->14
13-->10
13-->Z
14-->15
14-->Z
14-->12
14-->6
14-->9
14-->T
14-->10
14-->11
14-->V
15-->12
15-->Z
15-->11
17-->19
17-->1A
17-->R
17-->V
17-->X
17-->12
17-->13
17-->14
18-->19
T-->1K
T-->M
T-->1O
T-->1Y
T-->Q
T-->21
T-->U
U-->Q
U-->16
U-->M
U-->P
U-->O
U-->21
```