Files
atomic-design-poc/docs/reference/openzaak-integration.md
T
ehoandClaude Sonnet 5 de3bff0d7f feat(zgw): OpenZaak create-zaak, first write slice (WP-50)
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>
2026-07-29 09:03:13 +02:00

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: ListCases and CreateZaak. Both return the existing DTOs, so each implementation owns its own mapping.
  • Data/LocalZaakSource.csdefault; reads the local SQLite ApplicationStore (offline, unchanged behaviour). CreateZaak is a pure passthrough of what the submit endpoint already computed locally — no external call.
  • Zgw/OpenZaakZaakSource.cs — the OpenZaak client; selected only when Zgw:Enabled=true. CreateZaak posts 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/cases GET and the /applications/{id}/submit POST both resolve IZaakSource from 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:

  1. POST zaak ({ZrcBaseUrl}/zaken) — zaaktype resolved from Zgw:ZaaktypeUrls[aanvraag.Type] (OpenZaak validates the URL by fetching it), bronorganisatie/verantwoordelijkeOrganisatie (RSIN) from config, identificatie set to the same reference ApplicationStore.Submit already generated — so the human-readable reference matches in both places, not two independently-generated ones.
  2. POST status ({ZrcBaseUrl}/statussen) — statustype resolved via a Catalogi GET (statustypen?zaaktype=..., lowest volgnummer); marks the zaak as freshly opened.
  3. POST rol ({ZrcBaseUrl}/rollen) — roltype resolved via a Catalogi GET (roltypen?zaaktype=...&omschrijvingGeneriek=initiator); betrokkeneIdentificatie.inpBsn set 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 the Zgw appsettings 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 past iat, so per-call minting is the recommended pattern. Hand-rolled (no Microsoft.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, attaches Authorization: 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

  1. Read — extend IZaakSource (or add a sibling interface, e.g. IDocumentSource) with the new operation; implement it on both LocalZaakSource and the OpenZaak source. Keep the return type the existing DTO so the FE never changes.
  2. Write (create-zaak, WP-50) — a create needs a zaaktype URL from Catalogi (OpenZaak validates it by fetching), then usually a follow-up status + rol. Route it through the existing submit/mutation seam.
  3. 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:

  1. The BFF guards everything against upstream systems. OpenZaak's foreign model — URL-as-identity, a zaaktype that is a URL into another service, {count,next,previous, results} pagination, HS256 JWT auth — never leaves the BFF. ZgwZaakMapper translates it into the BFF's own ApplicationSummaryDto; IZaakSource makes the boundary swappable (LocalZaakSource vs OpenZaakZaakSource return the same DTO).
  2. The Angular app guards itself against the BFF. infrastructure/ is the only layer that touches the network (lint-enforced); every response crosses a parse* (Result) trust boundary + a toDomain mapper 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, the parse*/toDomain pairs) 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 + IZaakSource server-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 in ZgwZaakMapper show 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