Files
register-referentie/docs/architecture/adr-0008-read-projection-store.md
Niek Otten 0d0778036e
All checks were successful
CI / lint (pull_request) Successful in 1m0s
CI / build (pull_request) Successful in 45s
CI / unit (pull_request) Successful in 54s
CI / mutation (pull_request) Successful in 2m16s
CI / verify-stack (pull_request) Successful in 5m34s
docs(arch): ADR-0008 read projection store + demo note for the event path (refs #7)
ADR-0008 records the read-projection design: one rebuildable store shared by the Event
Subscriber (writer) and projection-api (reader) as one CQRS bounded context (reconciled
with §8.5), idempotency + rebuild from the notification log (no OpenZaak access, §8.1),
the deferred bsn/naam, and the new EF Core + Npgsql dependency. Add a demo-script entry
walking the OZ→NRC→subscriber→projection-api path and wire both into the MkDocs nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:11:36 +02:00

5.8 KiB

ADR-0008: The read projection — a shared, rebuildable store with a writer and a reader

  • Status: Accepted
  • Date: 2026-06-30
  • Deciders: Respellion engineering
  • Relates to: S-06 (#7); builds on ADR-0001 (loose coupling), ADR-0007 (#56, OZ→NRC wiring); first EF Core usage in the repo

Context

S-06 (#7) adds the upstream event path's destination: an Event Subscriber that consumes NRC notifications and a read projection the openbaar register reads. The walking-skeleton projection (PRD §8.4) holds one row per zaak — id, bsn, naam_placeholder, status — and must be idempotent (NRC redelivers and reorders, CLAUDE.md §8.6) and rebuildable (a derived artefact, never a write-only source of truth).

Two design questions had no obvious answer:

  1. Where does bsn come from? The NRC zaken/zaak/create notification carries only the zaak URL plus the fixed kenmerken (bronorganisatie, zaaktype, vertrouwelijkheidaanduiding). It does not carry the bsn. Reading it means calling a ZGW API — which only the ACL may do (CLAUDE.md §8.1). The issue's "Touches" lists only event-subscriber + projection-api, not the ACL.
  2. Who owns the projection schema? The subscriber writes the projection; the projection-api reads it. CLAUDE.md §8.5 says "no direct DB access across services; each service owns its schema." Two deployables on one table looks like a violation.

Decision

One Postgres database is the read projection. The Event Subscriber writes it (projector) and the projection-api reads it (query); both are processes of the single "Read Projection" bounded context and share one schema, defined in a shared Projection.ReadModel library. bsn is deferred.

  • Schema ownership. The read model — register_projection plus the subscriber's processed_notifications log — lives in services/projection-api/Projection.ReadModel (EF Core + Npgsql). Both services reference it. This is the textbook CQRS read-model split (one writer, one reader over one derived store), not the cross-domain DB reach §8.5 forbids: no domain owns write-state here; the projection is rebuildable (§8.4). §8.5 still holds for every domain database.
  • Idempotency is the primary key on processed_notifications.key (a deterministic key derived from the immutable notification content). A duplicate insert raises a unique violation, caught and reported as "already recorded", so the duplicate never reaches the projection. The projection upsert is itself idempotent on the zaak id, a second line of defence.
  • Rebuild replays the log, not OpenZaak. POST /admin/rebuild clears register_projection and reprojects every row in processed_notifications. So "rebuildable" needs no ZGW access (§8.1) and no ACL dependency — keeping S-06 within its stated scope.
  • bsn and naam_placeholder are deferred. They are columns (nullable) but the minimal slice populates only id + status (INGEDIEND) from the notification. Populating personal data requires reading the zaak through the ACL (§8.1) and is its own follow-up; the column shape is in place so that change is additive.
  • New dependency: EF Core 10 + Npgsql.EntityFrameworkCore.PostgreSQL. What it gives us: a migrated relational schema, LINQ queries, and a clean port implementation. What we'd write instead: hand-rolled SQL + a migration runner. Risk: ORM complexity and an extra dependency graph — bounded here to a tiny two-table read model. dotnet-ef is pinned as a local tool for migrations; NuGetAuditMode=direct keeps EF's design-time-only tooling transitive out of the audited, shipped graph.

The end-to-end path is verified by a runner-safe live-stack smoke (infra/run-projection-check.sh, the verify-projection step of the verify-stack job, #58): register an abonnement at the real Event Subscriber's callback, create a zaak, assert projection-api serves an INGEDIEND row — all in-network, reaching services by container IP (ADR-0006/0007).

Consequences

  • Positive: the upstream event path reaches a queryable projection; idempotent and rebuildable without OpenZaak; S-06 stays inside its stated touch-set (no ACL change); the projection-api is ready for S-09 to tighten public-safe field filtering.
  • Negative / deferred:
    • bsn/naam_placeholder stay empty until a follow-up wires zaak reads via the ACL.
    • The abonnement is registered by the verify harness (by container IP), not provisioned persistently — ADR-0007 already deferred a persistent abonnement, and a single-label service host is not URL-valid for NRC, so persistent registration needs a dotted network alias. Tracked as a follow-up; a plain make up therefore needs the abonnement registered before the event path flows.
    • Two services share one database. Acceptable for a derived read model; revisit if the read and write sides ever need independent scaling or storage.

Alternatives considered

  • Subscriber reads OpenZaak directly to fill bsn — rejected: breaks §8.1 (only the ACL talks to ZGW) and would need its own ADR to bend the rule.
  • Extend the ACL with a zaak-read operation, consumed as a library — viable and §8.1-clean, but it grows S-06 beyond its stated scope (touches the ACL) and pulls personal-data handling forward; deferred to a follow-up.
  • projection-api owns the DB and exposes an internal write endpoint the subscriber calls — rejected for the walking skeleton: adds an HTTP hop and a write surface on a read service for no current benefit over a shared, rebuildable read model.
  • Separate databases for the log and the projection — rejected as premature: both are the read side's private, rebuildable state; one DB is simpler and still honours §8.5's intent.