Files
ehoandClaude Opus 5 6cfd70eeeb fix(backend): resolve besluit endpoint's id via Referentie, not local PK
POST /beoordeling/{id}/besluit always 404'd against a real OpenZaak: {id} is the
FE-facing case id from IZaakSource.ListCases, which under OpenZaakZaakSource is the
ZGW zaak's own uuid, not ApplicationStore's primary key. Resolve the case through
ListCases first (same seam the GET sibling already uses), then to the local Aanvraag
via its Referentie — the one identifier stable across both sources.

Adds ApplicationStore.GetByReferentie and a regression test that reproduces the
divergence with a decorating IZaakSource test double instead of a live OpenZaak.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:20:36 +02:00

30 KiB
Raw Permalink 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 acting citizen resolved by the identity seam (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 shortcut still standing: "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. The "no compensating transaction" gap this section used to flag here is closed by WP-60 — see "Write resilience" below.

Write resilience (WP-60)

The local write (ApplicationStore.Submit, DocumentStore.Add/Link) and its paired ZGW write aren't transactional — this section covers what happens when the ZGW half fails after the local half already committed, closing the one gap the sections above used to flag as needing "retry/reconciliation or an outbox" before this integration could be called production-ready. Deliberately not an outbox: three write paths, each triggered by exactly one interactive request, don't justify a persisted queue (which would also need to carry the acting citizen's BSN for the JWT's audit claims — PII in a new table) — see WP-60 for the full reasoning.

  • Bounded retry, in ZgwHttpClient. Every ZGW call gets up to 3 attempts (200ms, doubling) on transport-shaped failures — 429/502/503/504/408, connection errors, timeouts — with a fresh request and JWT per attempt (a sent request/content can't be resent). 500 is deliberately not retried: it can follow a partial commit on the two non-idempotent POSTs (/statussen, /rollen), so retrying risks a duplicate write. The create-zaak/document POSTs are additionally safe to retry because OpenZaak enforces uniqueness on (bronorganisatie, identificatie) — and WP-50/51 already set identificatie to the locally-generated reference/document id, so a retry after a lost response 400s instead of duplicating.
  • The local write is never rolled back. Un-submitting a local aanvraag after a partial ZGW failure (e.g. the zaak POST succeeded but /statussen didn't) would let the citizen resubmit under a new reference, orphaning the first zaak — worse than leaving it flagged.
  • A caught ZGW failure is flagged, not silent. Program.cs's submit endpoint wraps CreateZaak and LinkToZaak in separate try/catches (separate so a create-zaak failure doesn't also skip the still-local document link) and, on catch, logs the error, sets Aanvraag.ZgwError (non-null = "the ZGW side of this submit didn't complete"), and records a zgw:divergence audit row (same AuthzAuditStore trail every other decision uses, visible at /beheer/audit) — see RecordZgwDivergence. The endpoint still returns 200 with the local reference/status: that's truthful (the reference is what would become the zaak's identificatie) and never branches on Zgw:Enabled (an offline LocalZaakSource never throws, so the catch is dead code there). The besluit endpoint (WP-66, see below) wraps IZaakSource.RecordBesluit in the same try/catch → RecordZgwDivergence shape — the second write pair this section's "Repair" bullet used to anticipate.
  • The document upload path flags differently. OpenZaakDocumentSource.Upload catches its own ZGW failure (config gap or transport) and logs it, but doesn't set a separate flag column — DocumentStore.Get(id).DrcUrl == null is already the meaningful "not registered in ZGW yet" detector LinkToZaak skips on, so no second mechanism is needed for that half.
  • Repair. No automated reconcile job exists yet — a flagged zaak is repairable on demand because its (would-be) identificatie always equals the aanvraag's Referentie, so a future admin action can GET /zaken?identificatie=... and either adopt the existing zaak or retry CreateZaak/RecordBesluit. WP-66 landed as the second write pair without needing an outbox — a real deployment is still the trigger to re-ask that question, not slice count on its own.

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.

vertrouwelijkheidaanduiding is driven by a per-document-type stamdata table (WP-59, Stamdata/documentconfidentialiteit.json, ADR-0004), falling back to "openbaar" for any category absent from it. Unlike the zaak side, an upload's ZGW failure (past DocumentStore.Add) is caught and logged rather than persisted as a separate flag column — see "Write resilience" below for why the two write paths differ.

Besluit write (WP-66)

POST /beoordeling/{id}/besluit (the behandelportal's decision endpoint, WP-65b) routes its ZGW side-effect through IZaakSource.RecordBesluit the same way submit routes through CreateZaak: the local write (ApplicationStore.RecordBesluit) always happens first and stays the record of truth, then OpenZaakZaakSource additionally POSTs a new statussen entry to the aanvraag's zaak (Aanvraag.ZaakUrl, set by CreateZaak).

There is no Besluiten (BRC) call here — the harness's catalogus (WP-56) provisions only a begin/eind statustype pair per zaaktype (Ontvangen/Afgehandeld), not one per decision outcome, so a real Besluiten API integration would need its own besluittype provisioning first (still "later" in the table above). Instead this reuses the exact statustype-resolution pattern CreateZaak already has (FirstStatustypeUrlAsync), just picking the highest volgnummer (LastStatustypeUrlAsync) instead of the lowest, and carries the besluit (Goedkeuren/Afwijzen/MeerInfoOpvragen) plus the behandelaar's toelichting in the status's free-text statustoelichting field so the outcome is still visible on the ZGW side.

RecordBesluit is a no-op if the aanvraag never got a zaak (Zgw:Enabled was off at submit time, or the create diverged) — same "nothing to do" skip LinkToZaak uses for a null zaakUrl. LocalZaakSource.RecordBesluit is a no-op outright — the local decision already IS the record of truth there. caller is the acting MedewerkerCaller (WP-62), so the minted ZGW JWT's audit claims reflect the behandelaar, not a static identity.

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. Every request also carries Accept-Crs/Content-Crs: EPSG:4326 — every ZGW call must declare a coordinate reference system even when no geometry is involved, or a real OpenZaak 412s ("Content-Crs header ontbreekt"). This was missing until WP-54's live harness caught it — the stub-handler tests never modelled the header, so it had shipped silently since WP-49/50.
  • ZgwZaakMapper.cs — the anti-corruption map: ZGW Zaak → ApplicationSummaryDto. This is where URL identity becomes the trailing uuid; Type takes the internal aanvraag-type key (AanvraagTypeFor, below) — a real bug (found via a live behandelportal walkthrough, fixed post-WP-66) had this carrying OpenZaak's human zaaktype label instead, which the FE's AANVRAAG_TYPES trust boundary always rejected.
  • OpenZaakZaakSource.cs — follows {count,next,previous,results} pagination, maps each zaak's zaaktype URL back to the internal key via Zgw:ZaaktypeUrls (AanvraagTypeFor — a local lookup, no Catalogi round-trip), attaches Authorization: Bearer <jwt>.
  • OpenZaakDocumentSource.cs — DRC upload + zaak-link (WP-51), same auth/JSON pattern.
  • NotificatieDto.cs + the POST /api/v1/zgw/notificaties endpoint (Program.cs, WP-52) — the inbound NRC webhook, not a source/mapper: see the dedicated section below.

Notificaties (NRC) webhook — inbound, WP-52

Unlike ZRC/ZTC/DRC (which the BFF calls outbound as a client), the Notificaties API calls this BFF — OpenZaak POSTs a NotificatieDto-shaped body to POST /api/v1/zgw/notificaties on every event on a subscribed kanaal. Auth is inverted too: no per-call JWT, just a fixed shared secret compared in constant time (CryptographicOperations.FixedTimeEquals) against ZgwOptions.NotificatieAuthorization — an unconfigured (empty) secret rejects every call, never accepts. Every attempt (accept or reject) is written to the same AuthzAuditStore the authz gate uses (action="zgw:notificatie", resource=hoofdObject — a URL, not PII, role="nrc") via the store directly, since there's no Principal for an NRC caller to run through the AuditAuthz helper.

There is no cache to invalidate today (/admin/cases and every other read already goes straight to IZaakSource per call), so a valid notification's only visible effect right now is the audit row proving the round-trip works end-to-end. Add real invalidation at the // ponytail: marker in Program.cs if a cache is ever introduced.

A real deployment provisioning is out-of-band, one-time config against a live OpenZaak — not app code. OpenZaak does not serve the Notificaties API itself — it's a separate application (open-notificaties, its own image/DB/celery stack). Register the abonnement once (e.g. via Open Notificaties' admin UI or a POST to its Abonnementen API) pointing at this BFF's public URL:

{
  "callbackUrl": "https://<this-bff>/api/v1/zgw/notificaties",
  "auth": "<same value as Zgw:NotificatieAuthorization>",
  "kanalen": [{ "filters": {}, "naam": "zaken" }],
}

auth is sent verbatim as the Authorization header on every callback (the NRC's auth_type=api_key default) — no Bearer prefix, matching this endpoint's plain string compare.

The dev harness (WP-58) skips the NRC entirely — see "Notifications-enabled profile" below.

Identity — the acting citizen (WP-53)

Everything above used to hardcode a single owner (DocumentStore.DemoOwner) and a single static ZGW audit identity (ZgwOptions.UserId/UserRepresentation). WP-53 replaced both with one per-request CallerIdentity (subject BSN + display name + role, Domain/Authorization/ CallerIdentity.cs):

  • Resolution: an IIdentityProvider runs once per request (middleware in Program.cs, right after the correlation-id middleware) into HttpContext.Items, read back everywhere via ctx.Caller(). StubIdentityProvider (the only implementation today, not a security boundary) reads the existing X-Role header (unchanged — mirrors the FE's ?role= toggle) plus a new X-Subject header for the BSN, defaulting to the single seeded citizen — so every request that doesn't send X-Subject (which is every request today; the FE never sends it) behaves exactly as before this WP. A production provider swaps in real OIDC/DigiD claims without touching a single consumer.
  • Authz.ResolvePrincipal(ctx) kept its exact signature — it now reads ctx.Caller().Role instead of the header directly, so its ~15 call sites across Program.cs needed no changes.
  • Ownership: every endpoint that used to pass DocumentStore.DemoOwner to a store (ApplicationStore, DocumentStore, BriefStore) now passes ctx.Caller().Bsn.
  • The ZGW JWT (ZgwTokenProvider) grew a Mint(CallerIdentity) overload alongside the original parameterless Mint(): citizen-scoped calls (create-zaak, upload, zaak-link, the citizen's own case list) mint with the caller's BSN/name as user_id/user_representation; calls not tied to one citizen (the admin cross-owner list, Catalogi metadata lookups) keep minting with the BFF's own system identity from ZgwOptions. ZgwHttpClient.GetAsync/ PostAsync take an optional CallerIdentity? that picks which Mint overload runs.
  • Citizen-scoped reads: IZaakSource gained ListMyCases(CallerIdentity, now) alongside the existing admin-only ListCases(now). LocalZaakSource filters ApplicationStore.List(bsn) (unchanged local behaviour); OpenZaakZaakSource appends ZGW's rol__betrokkeneIdentificatie__natuurlijkPersoon__inpBsn=<bsn> query filter to GET {ZrcBaseUrl}/zaken. GET /applications (the citizen's own dashboard) now routes through this instead of calling ApplicationStore directly — the last "reads a static store directly" gap the ACL caveat below used to flag for a citizen-facing endpoint.

WP-62 split CallerIdentity into the two actor kinds ADR-0002 §3 requires — a ZorgverlenerCaller (citizen, the WP-53 shape above) or a MedewerkerCaller (backoffice employee: MedewerkerId + Rollen, no BSN), backend-only, unused by any frontend until WP-64. StubIdentityProvider selects the medewerker kind when X-Medewerker is present (its value is the medewerkerId; X-Rollen is a comma-separated rollen list, defaulting to Behandelaar) — takes precedence over X-Subject; absent, every request today, falls through to the zorgverlener path unchanged. CallerIdentity.SubjectId (BSN or medewerkerId) is what ZgwTokenProvider.Mint now reads instead of .Bsn directly, so the ZGW JWT's user_id is correct for either kind with no further change (WP-66's besluit write mints this for free). The ownership-scoping seams (ctx.Zorgverlener(), IDocumentSource.Upload, IZaakSource .ListMyCases) are narrowed to ZorgverlenerCaller — a medewerker hitting a citizen-scoped SSP endpoint is a 500 today (unreachable, since no consumer sends X-Medewerker yet; WP-64 upgrades it to a 403 once real backoffice traffic exists). Authz.CanBeoordelen(CallerIdentity) is the first medewerker capability (rol-based, MedewerkerRol.Behandelaar), shipped only as a decision flag, never a rollen matrix.

The five ZGW APIs (context for later slices)

API Component Used by
Zaken ZRC slice 1 (read), WP-50 (create)
Catalogi ZTC WP-50/66 (statustype/roltype/resultaattype for create + besluit)
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.

Run against real OpenZaak (WP-54)

Everything above was, until WP-54, only proven against fixtures + a stub HttpMessageHandler — no live OpenZaak. backend/openzaak/ is a separate, opt-in docker-compose harness (never merged into the root docker-compose.yml, which stays FE+BFF-only) that brings up a real OpenZaak, seeds a minimal catalogus/zaaktype/zaak via a bootstrap script, and backs one xunit test (OpenZaakIntegrationTests.cs, tagged Category=Integration) that points the BFF at it with Zgw:Enabled=true. See backend/openzaak/README.md for the exact commands; the test is excluded from the default dotnet test run and from CI (--filter Category!=Integration) since it only passes with the harness up.

This is also where the Content-Crs/Accept-Crs header gap above was found: a real OpenZaak enforces ZGW's geo-header requirement in a way no stub-based test could catch, since a stub never rejects an unexpected (or missing) header. That is the harness's whole point — proving the seam against real protocol behaviour, not just the shapes we already assumed.

Notifications-enabled profile (WP-58)

The base harness above runs with NOTIFICATIONS_DISABLED: 'true' (no celery worker) — fine for proving the read/write ZGW seam, but it means a write to a notified resource never actually delivers anything. docker-compose.openzaak.notificaties.yml is an opt-in overlay that adds the one celery worker OpenZaak needs to deliver a notification, and flips that flag off. The two changes are inseparable: the moment NOTIFICATIONS_DISABLED is false, OpenZaak's NotificationsConfig must have a client configured or every write to a notified resource 500s and rolls back (NOTIFICATIONS_GUARANTEE_DELIVERY defaults true) — so bootstrap-notificaties.sh configures that client in the same step.

A real Notificaties API (NRC) is a separate application this harness doesn't stand up (see the "Notificaties webhook" section above) — reproducing it here (its own DB + celery + a real abonnement/kanaal registration) would roughly triple the harness for a benefit this dev loop doesn't need: there's only ever one subscriber (this repo's own BFF). Instead bootstrap-notificaties.sh points OpenZaak's NotificationsConfig straight at the BFF's webhook via a zgw_consumers.Service (auth_type=api_key, so the configured secret is sent verbatim as the Authorization header — exactly what the endpoint's plain string-compare expects). Same delivery proof (write → OpenZaak's celery worker → a real HTTP POST → the BFF's audit trail), far less to stand up and keep alive. A real deployment with more than one subscriber, or that needs kanaal-filtered fan-out, needs a real NRC + abonnement — this harness's shortcut doesn't model that.

The overlay's celery worker joins the repo root's own docker compose up network (by name, api) to reach the BFF — host.docker.internal:host-gateway was tried first, but this environment's rootless Podman doesn't route container→host-port traffic through it (DNS resolves, every TCP connect times out); container-to-container is the reliable path regardless of Docker vs. Podman. That means the notifications profile needs the repo root's docker compose up (or an equivalent api container on that network) running too, with Zgw__NotificatieAuthorization set:

docker compose run --rm -d --name atomic-design-poc-api-1 --service-ports \
  -e Zgw__NotificatieAuthorization='<a secret>' api      # repo root

cd backend/openzaak
docker compose -f docker-compose.openzaak.yml -f docker-compose.openzaak.notificaties.yml up -d
./bootstrap-catalogus.sh
BFF_AUTH='<the same secret>' ./bootstrap-notificaties.sh
BFF_AUTH='<the same secret>' ./verify-notificatie.sh   # proves a real delivery, end to end

Verified live in-session: the preflight in bootstrap-notificaties.sh proved the BFF's auth gate both ways (204 with the secret, 401 without/wrong), verify-notificatie.sh found the delivered zgw:notificatie/allow audit row for the PATCHed zaak, and re-running both scripts against the already-configured client stayed idempotent (no errors, no duplicate Service rows).

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>"
  },
  // WP-52 (Notificaties): NRC base URL — a SEPARATE host/app from OpenZaak itself
  // (documentation/provisioning only, no outbound call) + the shared secret NRC must send
  // back on every webhook POST.
  "NrcBaseUrl": "https://open-notificaties.example/api/v1",
  "NotificatieAuthorization": "<same value registered in the abonnement's `auth` field>"
}

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 (admin + citizen-scoped) + create path (WP-49/50/53), IDocumentSource covers upload + zaak-link (WP-51), the inbound POST /zgw/notificaties webhook (WP-52) closes the read/write/document/notify arc, and WP-53 threaded a real per-request CallerIdentity through all of it (ownership + the ZGW audit claims), and WP-54 added a docker OpenZaak harness + opt-in integration test proving the seam against a live instance (and, in doing so, caught the missing Content-Crs/Accept-Crs headers noted above). Other BFF endpoints (reference data like SeedData's BRP/DUO mimics) still read static stores directly — ACL-ready (the DTO seam exists) but not yet swappable, and not part of this arc. That closes the phase-9 OpenZaak/ZGW arc (WP-49..54).

See also