- 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>
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
handlerNameto a stable string (rename = re-projection — be deliberate); - implement
apply(event, ctx)and treatctx.eventIdas the unit of idempotency.
Subclasses MUST NOT:
- call
applydirectly — always go throughdispatch(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:
- Define
I<Name>ReadRepository(extendingIReadRepository) underapplication/repositories/. - Export a paired injection symbol
<NAME>_READ_REPOSITORY. - Implement
Prisma<Name>ReadRepositoryunderinfrastructure/repositories/and bind it inReadModelsModule. - Re-export the symbol from the module's
index.tsso 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_offsetmigration — owned by GOO-187. - No projectors registered.
- No materialized views or refresh job — Phase 1.
- No reconciliation job — Phase 2.
- No
X-Data-Freshness-Secondshelper — 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.