# `read-models` module Phase 0 skeleton for the CQRS read-model expansion described in [RFC-003](../../../docs/adr/0003-cqrs-read-models.md) (the ADR itself lands with [GOO-193](/GOO/issues/GOO-193); until then RFC-003 lives on [GOO-94](/GOO/issues/GOO-94)). ## Layout ``` read-models/ domain/ projection-context.ts # ProjectionContext, ProjectableEvent projection-offset-store.ts # IProjectionOffsetStore port + DI symbol read-repository.ts # IReadRepository convention marker application/ projectors/ projector.base.ts # Projector base class repositories/ # IReadRepository interfaces (Phase 2/3) infrastructure/ refresh/ # mat-view refresh cron (Phase 1) reconciliation/ # nightly drift checker (Phase 2+) testing/ in-memory-projection-offset-store.ts # unit-test harness read-models.module.ts index.ts ``` This mirrors the layout RFC-003 §5 specifies; intentionally **no `presentation/`** because read models are infrastructure for other modules' query handlers, not their own HTTP surface. ## The projector contract Every read-model projector extends `Projector` and implements: ```ts @EventsHandler(MyDomainEvent) export class MyProjector extends Projector { readonly handlerName = 'my-projector.v1'; protected async apply(event: MyDomainEvent, ctx: ProjectionContext) { // write to your read model } } // glue (one of): @EventsHandler(MyDomainEvent) export class MyProjectorGlue implements IEventHandler { constructor(private readonly projector: MyProjector) {} handle(event: MyDomainEvent) { return this.projector.dispatch(event); } } ``` Subclasses MUST: - set `handlerName` to a **stable string** (rename = re-projection — be deliberate); - implement `apply(event, ctx)` and treat `ctx.eventId` as the unit of idempotency. Subclasses MUST NOT: - call `apply` directly — always go through `dispatch(event)`; - write to write-model tables — read models are read-only from the API surface, only mutated by their owning projector or refresh job; - implement their own deduplication — the base class already does it via `IProjectionOffsetStore`. ## The offset / idempotency contract RFC-003 §0 mandates `(eventId, handlerName)` idempotency: > The `(eventId, handler)` offset table is non-negotiable. Land it in > Phase 0 with a unit-test harness so every Phase 2/3 projector inherits it. This module ships the **port** (`IProjectionOffsetStore`, `PROJECTION_OFFSET_STORE`) and an in-memory implementation for tests. The Prisma-backed implementation — including the `projection_offset(event_id, handler_name, applied_at, payload_hash)` migration and the transactional wrapper — lands with [GOO-187](/GOO/issues/GOO-187). The base class enforces the contract by calling `recordIfAbsent` BEFORE `apply`. Re-deliveries observe `applied: false` and are skipped. The offset row is intentionally **not rolled back on `apply` failure** in Phase 0 — this is the conservative choice (RFC-003 §7) and is healed by the nightly reconciliation job that lands in Phase 2. `eventId` is currently derived from `${aggregateId}:${occurredAt.getTime()}:${eventName}` because the existing `DomainEvent` interface (`apps/api/src/modules/shared/domain/domain-event.ts`) does not yet carry a stable id. Override `deriveEventId` on your projector if your event type provides one. The id contract itself is finalised in [GOO-187](/GOO/issues/GOO-187); Phase 2/3 projectors should not bake assumptions about its format. ## The repository convention For each read model: 1. Define `IReadRepository` (extending `IReadRepository`) under `application/repositories/`. 2. Export a paired injection symbol `_READ_REPOSITORY`. 3. Implement `PrismaReadRepository` under `infrastructure/repositories/` and bind it in `ReadModelsModule`. 4. Re-export the symbol from the module's `index.ts` so query handlers in other modules can `@Inject(LISTING_CARD_READ_REPOSITORY)` without reaching into the read-models module's internals. Read repositories are READ-ONLY from the perspective of the rest of the API. The only writers are the projector that owns the read model (Option C) or the materialized-view refresh job (Option B). ## What Phase 0 is NOT - No `projection_offset` migration — owned by [GOO-187](/GOO/issues/GOO-187). - No projectors registered. - No materialized views or refresh job — Phase 1. - No reconciliation job — Phase 2. - No `X-Data-Freshness-Seconds` helper — separate Phase 0 ticket. - No kill-switch / chaos test — separate Phase 0 ticket. The skeleton exists so the next batch of PRs is purely additive. ## Coordination - Parent: [GOO-94](/GOO/issues/GOO-94) - Sibling (offset table + idempotency harness): [GOO-187](/GOO/issues/GOO-187) - ADR (write-up): [GOO-193](/GOO/issues/GOO-193)