Files
goodgo-platform/apps/api/src/modules/read-models
Ho Ngoc Hai f5118244b7 fix(a11y): resolve serious accessibility issues on search page (GOO-110)
- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark)
- Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger
- Add aria-pressed on list/map/split view-mode toggle buttons
- Add aria-expanded + aria-controls on mobile filter toggle button
- Add role="status" + aria-label="Đang tải..." on Suspense fallback

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-24 10:26:50 +07:00
..

read-models module

Phase 0 skeleton for the CQRS read-model expansion described in RFC-003 (the ADR itself lands with GOO-193; until then RFC-003 lives on 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<E> base class
    repositories/                    # I<Name>ReadRepository 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<E extends DomainEvent> and implements:

@EventsHandler(MyDomainEvent)
export class MyProjector extends Projector<MyDomainEvent> {
  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<MyDomainEvent> {
  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.

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; Phase 2/3 projectors should not bake assumptions about its format.

The repository convention

For each read model:

  1. Define I<Name>ReadRepository (extending IReadRepository) under application/repositories/.
  2. Export a paired injection symbol <NAME>_READ_REPOSITORY.
  3. Implement Prisma<Name>ReadRepository 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.
  • 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