- test-storybook:ci gets --maxWorkers=2 so the Jest runner stops spawning one headless Chromium per core and OOM-ing the Gitea runner host (the root cause). - storybook-a11y job gains a container resource ceiling (--cpus=2 --memory=4g) as a belt-and-suspenders guardrail; noted it needs a docker-mode act_runner. - openzaak-integration.md: add "Anti-corruption layer — two nested boundaries" teaching section (BFF ACL vs upstreams + FE ACL vs BFF, and the principles). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.3 KiB
OpenZaak / ZGW integration — how the BFF connects (& how to extend)
How the BFF sources cases from a real OpenZaak (ZGW APIs) while the frontend stays unchanged. For the why, see ADR-0005; this page is how the seam is built and how to add the next slice. Built in WP-49 (read-only zaken).
The one rule: OpenZaak sits behind the BFF, never in the browser
The Angular app only ever sees the BFF's decision DTOs (BFF-lite, ADR-0001). All ZGW awkwardness — URL-as-identity, cross-service joins, JWT auth, pagination — is absorbed by the .NET BFF. Flipping the data source from local SQLite to OpenZaak is a backend config change with zero frontend change and no api-client drift.
The seam (data source by config)
Data/IZaakSource.cs— the cases READ interface. Returns the existingApplicationSummaryDto, so each implementation owns its own mapping.Data/LocalZaakSource.cs— default; reads the local SQLiteApplicationStore(offline, unchanged behaviour).Zgw/OpenZaakZaakSource.cs— the OpenZaak client; selected only whenZgw:Enabled=true.- Wiring (
Program.cs):if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>() else AddSingleton<IZaakSource, LocalZaakSource>(). The/admin/casesendpoint resolvesIZaakSourcefrom DI — routes + DTOs untouched.
The ZGW client (backend/src/BigRegister.Api/Zgw/)
ZgwOptions.cs— bound from theZgwappsettings section:Enabled, per-service base URLs (ZrcBaseUrl,ZtcBaseUrl),ClientId,Secret,UserId,UserRepresentation. The five ZGW APIs are separate base URLs; slice 1 needs only Zaken (ZRC) + Catalogi (ZTC).ZgwTokenProvider.cs— mints an HS256 JWT per call (iss/client_id/iat/user_id/user_representation). No refresh flow — OpenZaak expires tokens 1h pastiat, so per-call minting is the recommended pattern. Hand-rolled (noMicrosoft.IdentityModel.*dependency).ZgwZaakMapper.cs— the anti-corruption map: ZGW Zaak →ApplicationSummaryDto. This is where URL identity becomes the trailing uuid and the zaaktype URL is resolved to a human label (the cross-service join).OpenZaakZaakSource.cs— follows{count,next,previous,results}pagination, resolves + caches zaaktype labels, attachesAuthorization: Bearer <jwt>.
The five ZGW APIs (context for later slices)
| API | Component | Used by |
|---|---|---|
| Zaken | ZRC | slice 1 (read), WP-50 (create) |
| Catalogi | ZTC | slice 1 (zaaktype label; also type URLs for create) |
| Documenten | DRC | WP-51 (upload + zaak↔document link) |
| Besluiten | BRC | later (formal decisions) |
| Notificaties | NRC | WP-52 (live status via webhooks, not polling) |
How to add the next slice
- Read — extend
IZaakSource(or add a sibling interface, e.g.IDocumentSource) with the new operation; implement it on bothLocalZaakSourceand the OpenZaak source. Keep the return type the existing DTO so the FE never changes. - Write (create-zaak, WP-50) — a create needs a
zaaktypeURL from Catalogi (OpenZaak validates it by fetching), then usually a follow-upstatus+rol. Route it through the existing submit/mutation seam. - Enforce server-side for anything the FE gates — a config value the FE echoes is never the authority (ADR-0001).
Coupling
Low and one-directional. Consumer coupling is near zero — IZaakSource is injected at one
endpoint, and the FE is fully decoupled by the DTO. The producer side is contained in Zgw/:
add a slice by adding a source method + a mapper case, not by touching the FE or the contract.
Watch the sync-over-async ponytail: note in OpenZaakZaakSource — make the cases read
path async if OpenZaak becomes the default.
Config
// appsettings.json — off by default (POC runs offline on the local store)
"Zgw": {
"Enabled": true,
"ZrcBaseUrl": "https://open-zaak.example/zaken/api/v1",
"ZtcBaseUrl": "https://open-zaak.example/catalogi/api/v1",
"ClientId": "big-register", "Secret": "<from a secret store>",
"UserId": "<session user>", "UserRepresentation": "<session name>"
}
Anti-corruption layer — two nested boundaries (what to learn)
This setup is an anti-corruption layer (ACL) twice over, and seeing them as a pair is the lesson worth taking away:
- The BFF guards everything against upstream systems. OpenZaak's foreign model —
URL-as-identity, a
zaaktypethat is a URL into another service,{count,next,previous, results}pagination, HS256 JWT auth — never leaves the BFF.ZgwZaakMappertranslates it into the BFF's ownApplicationSummaryDto;IZaakSourcemakes the boundary swappable (LocalZaakSourcevsOpenZaakZaakSourcereturn the same DTO). - The Angular app guards itself against the BFF.
infrastructure/is the only layer that touches the network (lint-enforced); every response crosses aparse*(Result) trust boundary + atoDomainmapper before any domain/UI code sees it (ADR-0001, ARCHITECTURE §6).
The DTO at /api/v1 is the membrane between them — which is why wiring OpenZaak touched zero
frontend code and produced zero api-client drift. That was the proof the ACL held.
Principles this demonstrates:
- An ACL is a mapping, not a passthrough. A DTO that is the upstream shape renamed is
corruption with extra steps; the valuable ACLs here (
ZgwZaakMapper, theparse*/toDomainpairs) actively translate a foreign model into a local one. - Put the ACL where trust changes, and make it the only place. One choke point per
boundary — the
Zgw/folder +IZaakSourceserver-side,infrastructure/client-side. - Decision DTOs and the ACL are complementary. BFF-lite (server decides, FE renders) is an ACL against business-rule drift, layered on the ACL against data-shape drift.
- A real seam is swap-testable offline. Because the ACL returns a stable DTO, the ZGW client is unit-testable with fixtures + a stub handler — no live OpenZaak.
- Mark the honest edges. The
ponytail:sync-over-async note and the "coarse status map" comment inZgwZaakMappershow where the ACL is deliberately thin — an ACL need not be complete on day one, but its shortcuts should be visible.
Caveat: today only the cases read path has a source interface (IZaakSource). Other BFF
endpoints still read SeedData/static stores directly — ACL-ready (the DTO seam exists) but not
yet swappable. That is the WP-50/51/52 roadmap.
See also
- ADR-0005 — OpenZaak behind the BFF — the decision.
- ADR-0001 — BFF-lite + decision DTOs — why the FE doesn't change.
- WP-49 (this), WP-50/51/52 (later slices).
backend/src/BigRegister.Api/Zgw/— the client;Data/IZaakSource.cs— the seam.- ZGW standard (VNG) · OpenZaak auth docs.