Local aanvraag/document writes and their paired ZGW writes aren't transactional; a ZGW failure after the local write succeeds used to diverge silently. ZgwHttpClient now retries transport-shaped failures (not 500, which can follow a partial commit on the non-idempotent statussen/rollen POSTs), and a ZGW failure that survives retry sets Aanvraag.ZgwError plus a zgw:divergence audit row instead of failing or diverging quietly. No outbox/reconcile job: three request-triggered write paths don't justify a persisted queue that would also need to carry citizen PII for the JWT audit claims. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5.0 KiB
ADR-0005 — OpenZaak (ZGW APIs) behind the BFF
Status: Accepted · Date: 2026-07-24
Context
The POC serves cases (aanvragen) from a local SQLite store. To grow toward production it must be able to source cases from a real Dutch Zaakgericht Werken (ZGW) backend — OpenZaak, the VNG reference implementation. ZGW is not one API but five separate services (Zaken/ZRC, Documenten/DRC, Catalogi/ZTC, Besluiten/BRC, Notificaties/NRC), each on its own base URL, with traits that make raw responses unfit to hand to a browser:
- resources are identified by full URLs, not bare ids;
- references between resources are URLs into other services (a zaak's
zaaktypelives in Catalogi), so a single screen means joining across services; - lists use a uniform
{count,next,previous,results}pagination envelope; - auth is a short-lived HS256 JWT signed with a client secret (no OAuth refresh), which
OpenZaak rejects an hour past
iat.
Two constraints shaped the decision: the frontend must not change (BFF-lite, ADR-0001 — the FE renders decision DTOs and never recomputes rules), and the POC must still run fully offline (no OpenZaak needed for local dev/CI).
The backend, however, had no data-access abstraction — endpoints called concrete static stores directly — and no outbound HTTP or JWT machinery. So there was no injection point to swap a data source behind.
Options
- FE talks to OpenZaak directly. Rejected: leaks ZGW shapes + the client secret to the browser, and contradicts BFF-lite.
- Rewrite the static stores in place to call OpenZaak. Rejected: no seam, no offline mode, all-or-nothing, untestable without a live server.
- Introduce a data-source interface behind the existing DTO contract, select the implementation by config. Chosen.
Decision
Put the OpenZaak anti-corruption layer in the .NET BFF, never in the browser. Introduce a
per-domain source interface (starting with IZaakSource for the cases read path) whose default
implementation reads the local SQLite store and whose alternate implementation calls OpenZaak —
selected by a config flag (Zgw:Enabled, default false). Each implementation maps into the
existing wire DTO (ApplicationSummaryDto), so the /api/v1 contract and the FE are
untouched. The BFF holds the client secret and mints a fresh JWT per outbound call.
This is deliberately a thin vertical slice (read-only zaken, WP-49); create/documents/ notifications follow the same seam in later slices (WP-50/51/52) rather than being scaffolded up front — the migration stance ADR-0001 already prescribes.
Consequences
- + The FE is production-ready as-is: swapping to OpenZaak is backend-only, behind one config flag, with zero DTO/api-client drift. The POC still runs offline (default = local).
- + The seam is unit-testable without a live server: the JWT minter, the ZGW→DTO mapper,
and the paginating source are all covered with fixtures + a stub
HttpMessageHandler. - + URL-as-identity and cross-service joins are contained in one mapper; nothing downstream sees a ZGW shape.
- − Only the cases read path has a source interface today; other endpoints still call static stores directly. Each future slice introduces its own seam as needed (not a big-bang repository refactor).
- −
IZaakSourceis synchronous (matching the existing sync endpoint + local store), soOpenZaakZaakSourcedoes sync-over-async; fine under ASP.NET Core (no sync-context), to be made async if OpenZaak becomes the default. Marked with aponytail:note at the call site. - Shipped with this ADR (WP-49):
IZaakSource+LocalZaakSource(default) +OpenZaakZaakSource(config-gated), theZgw/client (ZgwOptions,ZgwTokenProvider,ZgwZaakMapper), and the reference guide openzaak-integration.md. - Also shipped (WP-50):
IZaakSource.CreateZaak— the first write. Submitting an aanvraag now also creates a Zaak + Status + Rol in OpenZaak whenZgw:Enabled=true, routed through the existing submit endpoint with zero DTO change (same seam, same anti-corruption boundary). - Also shipped (WP-51):
IDocumentSource(LocalDocumentSource/OpenZaakDocumentSource, same config-gated seam shape) — an upload registers a Documenten/DRC enkelvoudiginformatieobject and, once a zaak exists, a submit links it in with a zaakinformatieobject. - Also shipped (WP-60): bounded retry in
ZgwHttpClientfor transport-shaped ZGW failures, plus a flagged (not silent) divergence —Aanvraag.ZgwError+ azgw:divergenceaudit row — when a ZGW write still fails after retry. No outbox/background worker (see WP-60 for the ladder check that ruled it out for this POC's write volume). - Deferred: real inbound OIDC/JWT auth (still header-stubbed), Notificaties/NRC webhooks (WP-52, shipped instead as a direct-to-BFF delivery in WP-58), adding OpenZaak to docker-compose, an automated reconciliation/repair job for a flagged divergence (WP-60).