Files
atomic-design-poc/docs/reference/openzaak-integration.md
T
ehoandClaude Sonnet 5 5807937229 feat(zgw): OpenZaak Documenten (DRC) upload + zaak link (WP-51)
Extends the OpenZaak seam with IDocumentSource, sibling of IZaakSource
(WP-49/50): an upload always lands locally first (DocumentStore stays
the record of truth for preview/download/audit) and, when
Zgw:Enabled=true, is also registered as a DRC enkelvoudiginformatie-
object; once a zaak exists (IZaakSource.CreateZaak now also returns
its ZaakUrl), submit links each document to it via zaakinformatie-
object. FE upload/list DTOs are unchanged.

- ZgwOptions gains DrcBaseUrl + a category->informatieobjecttype URL
  map (the document analogue of ZaaktypeUrls).
- LocalDocumentSource is the same DocumentStore.Add/Link calls the
  endpoints used to make inline — zero behaviour change offline.
- OpenZaakDocumentSource POSTs the eio then the zaak link, persisting
  the DRC url (DocumentStore.SetDrcUrl) so linking doesn't re-upload.
- Factored the GET/POST-with-bearer-JWT plumbing shared with
  OpenZaakZaakSource into ZgwHttpClient; shared the stub handler
  between the two source test classes as ZgwStubHandler.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-29 20:54:31 +02:00

14 KiB
Raw Blame History

OpenZaak / ZGW integration — how the BFF connects (& how to extend)

How the BFF sources (and now creates) cases, and uploads/links documents, 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), WP-50 (create-zaak), and WP-51 (Documenten/DRC upload + zaak link).

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. CreateZaak also returns the zaak's URL (ZaakUrl, null under the local source) so WP-51 can later link documents to it.
  • 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).
  • Data/IDocumentSource.cs — the documents seam (WP-51), sibling of IZaakSource: Upload and LinkToZaak. Data/LocalDocumentSource.cs is the same DocumentStore.Add/Link calls the upload/submit endpoints used to make inline; Zgw/OpenZaakDocumentSource.cs also registers each upload as a DRC document and links it to a zaak once one exists.
  • Wiring (Program.cs): if (Zgw:Enabled) registers OpenZaakZaakSource + OpenZaakDocumentSource, else LocalZaakSource + LocalDocumentSource. The /admin/cases GET, the /uploads POST, and the /applications/{id}/submit POST all resolve their seam 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, ZaakUrl) and returns the first two, unchanged, in SubmitApplicationResponse (ZaakUrl is persisted via ApplicationStore.SetZaakUrl for WP-51's document link, not returned to the FE). 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).

POST /uploads and POST /applications/{id}/submit route through IDocumentSource the same way submit routes through IZaakSource: the local write (DocumentStore.Add/Link) always happens first — it stays the record of truth for preview/download/audit regardless of Zgw:Enabled — and OpenZaakDocumentSource additionally does the DRC side-effect:

  1. Upload — POST enkelvoudiginformatieobjecten ({DrcBaseUrl}) with the file's base64 content, informatieobjecttype resolved from Zgw:InformatieobjecttypeUrls[categoryId] (the document analogue of ZaaktypeUrls), identificatie set to the local document id. The returned DRC url is persisted (DocumentStore.SetDrcUrl) so the link step below doesn't need to re-upload.
  2. Link to zaak — once IZaakSource.CreateZaak has returned a ZaakUrl (persisted via ApplicationStore.SetZaakUrl), submit calls documents.LinkToZaak(documentIds, zaakUrl), which POSTs a zaakinformatieobjecten ({ZrcBaseUrl}) per document that has a DrcUrl. Documents uploaded before a zaak existed (or under a config gap) have no DrcUrl yet and are silently skipped — same "nothing extra to link" behaviour as the local source.

ZgwHttpClient (shared GET/POST-with-bearer-JWT plumbing) was factored out of OpenZaakZaakSource once OpenZaakDocumentSource needed the identical boilerplate.

ponytail shortcut: vertrouwelijkheidaanduiding is hardcoded to "openbaar" — a per-category confidentiality level would matter for production but isn't needed to prove the seam.

The ZGW client (backend/src/BigRegister.Api/Zgw/)

  • ZgwOptions.cs — bound from the Zgw appsettings section: Enabled, per-service base URLs (ZrcBaseUrl, ZtcBaseUrl, DrcBaseUrl), ClientId, Secret, UserId, UserRepresentation. The five ZGW APIs are separate base URLs; slices 13 need Zaken (ZRC), Catalogi (ZTC), and Documenten (DRC).
  • 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).
  • ZgwHttpClient.cs — shared GET/POST-with-bearer-JWT plumbing used by both OpenZaakZaakSource and OpenZaakDocumentSource.
  • 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>.
  • OpenZaakDocumentSource.cs — DRC upload + zaak-link (WP-51), same auth/JSON pattern.

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, like IDocumentSource, WP-51) with the new operation; implement it on both the local store and the OpenZaak source. Keep the return type the existing DTO so the FE never changes.
  2. Write (create-zaak WP-50, DRC upload/link WP-51) — a create/upload needs a type URL from Catalogi (OpenZaak validates it by fetching), then usually a follow-up call (status + rol for a zaak; zaakinformatieobject for a document). 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/IDocumentSource are each 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 (and its OpenZaakDocumentSource sibling) — make the read/write paths 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",
  "DrcBaseUrl": "https://open-zaak.example/documenten/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>"
  },
  // WP-51 (Documenten): upload category → informatieobjecttype URL map.
  "InformatieobjecttypeUrls": {
    "identiteit": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<uuid>",
    "diploma": "https://open-zaak.example/catalogi/api/v1/informatieobjecttypen/<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 covers the cases read + create path (WP-49/50) and IDocumentSource covers upload + zaak-link (WP-51). Other BFF endpoints still read SeedData/static stores directly — ACL-ready (the DTO seam exists) but not yet swappable. That is the WP-52 roadmap (notificaties), 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