Extends the IZaakSource seam (WP-49, read-only) with CreateZaak: submitting
an aanvraag now also registers a Zaak + Status + Rol in OpenZaak when
Zgw:Enabled=true, routed through the existing /applications/{id}/submit
endpoint with the FE response DTO unchanged (ADR-0001/ADR-0005 — the
endpoint never branches on the config flag itself, DI already picked the
implementation).
- ZgwOptions gains a Type→zaaktype-URL map + the two RSINs a Zaak needs.
- LocalZaakSource.CreateZaak is a pure passthrough of what the endpoint
already computes locally (zero behaviour change for the offline default).
- OpenZaakZaakSource.CreateZaak POSTs the zaak (identificatie = the same
local reference, so both stay in sync), resolves + POSTs the initial
status and the initiator rol (BSN) via Catalogi lookups, and maps the
result back into the submit response.
- Marked ponytail shortcuts: first-statustype/roltype-Catalogi-returns
(no per-type config) and no compensating transaction on partial failure
— both fine for a first slice against a demo backend.
Verified: full `npm run ci` green, zero api-client drift, 144/144 backend
tests (142 existing + 2 new stub-handler tests asserting the POST bodies
+ type→zaaktype mapping per the acceptance criteria).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
OpenZaak / ZGW integration — how the BFF connects (& how to extend)
How the BFF sources (and now creates) cases against 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) and WP-50 (the first write: create-zaak).
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 + (WP-50) WRITE interface:ListCasesandCreateZaak. Both return the existing DTOs, so each implementation owns its own mapping.Data/LocalZaakSource.cs— default; reads the local SQLiteApplicationStore(offline, unchanged behaviour).CreateZaakis a pure passthrough of what the submit endpoint already computed locally — no external call.Zgw/OpenZaakZaakSource.cs— the OpenZaak client; selected only whenZgw:Enabled=true.CreateZaakposts a Zaak, then a Status, then a Rol (see below).- Wiring (
Program.cs):if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>() else AddSingleton<IZaakSource, LocalZaakSource>(). The/admin/casesGET and the/applications/{id}/submitPOST both resolveIZaakSourcefrom DI — routes + DTOs untouched either way.
Create-zaak (WP-50) — the first write
POST /applications/{id}/submit already persists the aanvraag locally (ApplicationStore.Submit
— unconditionally, regardless of Zgw:Enabled, since draft/step/document bookkeeping stays
local either way) and only THEN calls zaken.CreateZaak(submitted, now). The submit endpoint
never branches on Zgw:Enabled itself — DI already picked the implementation, so the endpoint
just asks the seam for (Referentie, Status) and returns exactly that in the unchanged
SubmitApplicationResponse. Under the default (local) source this returns precisely what was
just computed; under OpenZaak, three calls happen in order:
- POST zaak (
{ZrcBaseUrl}/zaken) —zaaktyperesolved fromZgw:ZaaktypeUrls[aanvraag.Type](OpenZaak validates the URL by fetching it),bronorganisatie/verantwoordelijkeOrganisatie(RSIN) from config,identificatieset to the same referenceApplicationStore.Submitalready generated — so the human-readable reference matches in both places, not two independently-generated ones. - POST status (
{ZrcBaseUrl}/statussen) —statustyperesolved via a Catalogi GET (statustypen?zaaktype=..., lowestvolgnummer); marks the zaak as freshly opened. - POST rol (
{ZrcBaseUrl}/rollen) —roltyperesolved via a Catalogi GET (roltypen?zaaktype=...&omschrijvingGeneriek=initiator);betrokkeneIdentificatie.inpBsnset to the aanvraag's owner (BSN) — the current stand-in for real identity (WP-53).
The created zaak's identificatie becomes the returned Referentie; its status maps to the
same coarse InBehandeling shape ZgwZaakMapper already uses for a freshly-opened zaak
(ZgwZaakMapper.ToCreatedStatusDto).
ponytail shortcuts, marked at the call sites: (a) "first statustype/roltype Catalogi returns"
rather than a fully-configured per-type map — fine while a zaaktype has exactly one initial
status and initiator role; (b) no compensating transaction — if any ZGW call throws, the
aanvraag is already Submitted locally with no matching zaak (acceptable for a demo backend;
a production arc needs retry/reconciliation or an outbox before trusting this dual-write).
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>",
// WP-50 (create-zaak): RSINs + the aanvraag-type → zaaktype URL map.
"Bronorganisatie": "<RSIN>", "VerantwoordelijkeOrganisatie": "<RSIN>",
"ZaaktypeUrls": {
"registratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
"herregistratie": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>",
"intake": "https://open-zaak.example/catalogi/api/v1/zaaktypen/<uuid>"
}
}
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: IZaakSource now covers the cases read + create path (WP-49/50). Other BFF
endpoints still read SeedData/static stores directly — ACL-ready (the DTO seam exists) but not
yet swappable. That is the WP-51/52 roadmap, plus the two cross-cutting WPs the arc needs for
production: WP-53 (a real per-request identity seam + citizen-scoping — today the owner/BSN
is stubbed) and WP-54 (a docker OpenZaak harness + opt-in integration test — today everything
is fixture/mock-tested against no live instance).
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 (CRUD arc), WP-53/54 (identity seam + integration harness).
backend/src/BigRegister.Api/Zgw/— the client;Data/IZaakSource.cs— the seam.- ZGW standard (VNG) · OpenZaak auth docs.