Files
atomic-design-poc/docs/reference/openzaak-integration.md
T
ehoandClaude Opus 4.8 dfd64baba8
CI / frontend (push) Successful in 2m20s
CI / backend (push) Successful in 1m51s
CI / e2e (push) Successful in 3m20s
CI / storybook-a11y (push) Failing after 8m35s
CI / semgrep (push) Successful in 1m5s
CI / api-client-drift (push) Successful in 1m52s
docs(backlog): add WP-53 (identity seam + citizen-scoping) and WP-54 (OpenZaak harness)
The two highest-value OpenZaak roadmap gaps, each written self-contained (a "current
state" handoff section) so a fresh session can execute from the file + repo alone:

- WP-53: replace the stubbed owner/BSN with a real per-request CallerIdentity
  (pluggable stub, not DigiD), threading it into Authz, the ZGW JWT user claims, and
  a citizen-scoped read (rol__…__inpBsn). Production-blocking for a real deployment.
- WP-54: a separate docker-compose OpenZaak + scripted bootstrap + opt-in
  Category=Integration test — makes 50/51/52 developable against a live instance
  instead of only fixtures; kept out of the default gate.

Indexed in the backlog README (rows + phase-9 ordering note) and cited from
openzaak-integration.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:05:58 +02:00

7.7 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 existing ApplicationSummaryDto, so each implementation owns its own mapping.
  • Data/LocalZaakSource.csdefault; reads the local SQLite ApplicationStore (offline, unchanged behaviour).
  • Zgw/OpenZaakZaakSource.cs — the OpenZaak client; selected only when Zgw:Enabled=true.
  • Wiring (Program.cs): if (Zgw:Enabled) AddHttpClient<IZaakSource, OpenZaakZaakSource>() else AddSingleton<IZaakSource, LocalZaakSource>(). The /admin/cases endpoint resolves IZaakSource from DI — routes + DTOs untouched.

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>"
}

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: 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, 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