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>
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:
- Where does
bsncome from? The NRCzaken/zaak/createnotification carries only the zaak URL plus the fixedkenmerken(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 onlyevent-subscriber+projection-api, not the ACL. - 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_projectionplus the subscriber'sprocessed_notificationslog — lives inservices/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/rebuildclearsregister_projectionand reprojects every row inprocessed_notifications. So "rebuildable" needs no ZGW access (§8.1) and no ACL dependency — keeping S-06 within its stated scope. bsnandnaam_placeholderare deferred. They are columns (nullable) but the minimal slice populates onlyid+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-efis pinned as a local tool for migrations;NuGetAuditMode=directkeeps 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_placeholderstay 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 uptherefore 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.