Add reads field to UseCaseManifest, update CLAUDE.md with Q0-Q3 rules, add ./reader subpath to AGENTS.md exports table, and cascade reader conventions through conformance quickref, adding-a-feature guide, and scaffolding guide. Moves gen reader from deferred to planned.
14 KiB
ADR-026 — Cross-feature synchronous readers
Status: Accepted
Date: 2026-05-28
Context
The monorepo's vertical-slice architecture (ADR-006) enforces strict feature isolation: each vertical owns its data end-to-end, and cross-feature communication flows through the event bus (ADR-015, rule E0). This works well for reactions ("user signed up → send welcome email"), but the architecture has no mechanism for synchronous domain queries across features.
Three concrete scenarios expose the gap:
- Permission checks. Blog's
createArticleneeds to verify the author has the "editor" role. The raw user record is available via Payload'srelationTo, but evaluating "does role X grant permission Y in context Z?" is domain logic that belongs to the auth vertical. - Computed state. A billing feature needs to know whether a subscription is active after applying trial logic, grace periods, and plan rules. That evaluation belongs to the subscriptions vertical.
- Validated existence. A comments feature needs to verify a referenced article exists and is in "published" status — a check that includes blog-domain invariants, not just a row lookup.
Payload's relationTo handles raw data joins at the database level (and should continue to be used for that), but it cannot evaluate business rules owned by another vertical. Events cannot answer synchronous questions. The architecture needs a third cross-feature mechanism.
Decision
1. Introduce readers: synchronous, read-only cross-feature query contracts.
A reader is a minimal interface exported by a feature that exposes domain queries to other verticals. It complements events (async reactions) and relationTo (raw data joins) without replacing either.
| Cross-feature need | Mechanism | Sync/Async | Example |
|---|---|---|---|
| Raw data join | Payload relationTo |
Sync (DB) | Article card shows author name |
| Domain query | Reader | Sync (code) | "Does user have editor role?" |
| Reaction / side effect | Event bus (ADR-015) | Async | "User signed up → send email" |
| Deferred work | Job queue (ADR-015) | Async | "Resize uploaded image" |
| State delivery / push | Realtime (ADR-016) | Async | "New comment appeared" |
2. Four rules, parallel to events (E0/E1) and jobs (J0).
- Q0 — Readers are for cross-feature synchronous domain queries only. In-feature reads are direct use-case calls. If the caller and the data owner are in the same vertical, use the use case directly — don't route through a reader.
- Q1 — Reader contracts (interfaces) are public; implementations are private. The owning feature exports
I<Feature>Readerfrom a./readersubpath. The implementation class (<Feature>Reader) is internal, constructed by the feature's binder. Consumers import the type only. Parallel to rule E1 for event handlers. - Q2 — Readers are strictly read-only. Cross-feature writes go through events. A reader may only delegate to use cases declared
mutates: falsein the feature manifest. Enforced byReadOnly<F>TypeScript brand at compile time andassertReaderPurityat boot time. If you need to tell another vertical that something happened, publish an event. - Q3 — Reader cycles are a design error. If Feature A reads from Feature B and Feature B reads from Feature A, the boundaries are wrong. Resolution strategies: (a) one direction is a UI composition concern — compose at the app layer instead; (b) one direction can be async — use an event; (c) the two features should be one vertical.
3. One reader per feature, grown on demand.
Each feature that exposes cross-feature queries ships a single I<Feature>Reader interface (e.g., IAuthReader, ITenantReader). The interface starts minimal and grows as consumers need more methods. If the interface becomes bloated, that's a signal the vertical is too fat.
4. Readers wrap existing use cases — they don't add domain logic.
The reader is a thin facade over the owning feature's use cases. It does not contain business rules itself. If a reader needs logic that doesn't exist as a use case, the correct response is to create the use case first (manifest-first ordering), then have the reader delegate to it.
// packages/auth/src/infrastructure/readers/auth.reader.ts (INTERNAL)
export class AuthReader implements IAuthReader {
constructor(
private checkRole: ReadOnly<ICheckUserRoleUseCase>,
private getUser: ReadOnly<IGetUserUseCase>,
) {}
async hasRole(userId: string, role: string): Promise<boolean> {
return this.checkRole({ userId, role });
}
async exists(userId: string): Promise<boolean> {
const user = await this.getUser({ id: userId });
return user !== null;
}
}
Because the reader wraps use cases, no MockReader class is needed. In dev-seed mode the same AuthReader class works — the use cases beneath it are backed by mock repositories populated with seed data. In consumer tests, an inline vitest mock of IAuthReader suffices.
5. Readers live under integrations/readers/, exported via ./reader subpath.
The reader is an outward-facing integration boundary, parallel to integrations/api/ (HTTP consumers) and integrations/cms/ (Payload admin). File layout:
packages/<feature>/src/
integrations/
api/ # outward: HTTP consumers
cms/ # outward: Payload admin
readers/ # outward: other verticals
<feature>.reader.interface.ts # IFeatureReader (PUBLIC)
<feature>.reader.ts # FeatureReader (INTERNAL)
<feature>.reader.test.ts
index.ts # exports type { IFeatureReader } only
The package.json exports map gains a ./reader entry:
{ "./reader": "./src/integrations/readers/index.ts" }
6. Wiring: binder returns reader, bindAll() threads it to consumers.
Feature binders that expose a reader return it:
// bindProductionAuth(ctx) returns { reader: IAuthReader }
const authResult = bindProductionAuth(ctx);
bindProductionBlog(ctx, { authReader: authResult.reader });
Consuming binders accept readers as a second parameter alongside ctx:
export function bindProductionBlog(
ctx: BindProductionContext,
readers: { authReader: IAuthReader },
): void;
Ordering in bindAll() is explicit — the owning feature binds first, then consumers. A cycle in bindAll() is a compile-time error (TypeScript cannot type the return before the call), which enforces rule Q3 structurally.
7. Manifest field: reads: ["<feature>"] per use case.
The feature manifest declares cross-feature read dependencies:
useCases: {
createArticle: {
mutates: true, // this use case mutates its OWN feature's data
reads: ["auth"], // this use case queries ANOTHER feature's reader (read-only on auth side)
publishes: [],
consumes: [],
audits: [],
},
}
Note: mutates and reads are orthogonal. mutates describes whether this use case writes to its own feature's repositories. reads describes which other features' readers it queries. A mutating use case can read from another feature's reader — the read-only constraint (Q2) is enforced on the provider side (the reader can only wrap non-mutating use cases), not on the consumer side.
Conformance gates verify:
- ESLint rule
no-undeclared-reader: Code calls a reader method but manifest doesn't declarereads. (Parallel tono-undeclared-event-publish.) - Boot assertion
assertReaderPurity: Every use case wired into a reader is declaredmutates: falsein the manifest. - Boot assertion
assertFeatureConformance: Everyreadsentry has a corresponding reader injected into the binder. pnpm conformance: Cross-feature reader closure — everyreads: ["auth"]resolves to a feature that exports./reader.
8. Read-only enforcement via ReadOnly<F> brand.
A new branded type prevents mutating use cases from being wired into readers at compile time:
type ReadOnly<F> = F & { readonly __readonly: unique symbol };
Use cases declared mutates: false receive the ReadOnly brand at bind time. The reader constructor only accepts ReadOnly-branded use cases. Passing a mutating use case produces a TypeScript error.
The brand is verified at boot time by assertReaderPurity, which cross-references the reader's wired use cases against the manifest's mutates field. If a mutates: true use case is wired into a reader, the app refuses to boot.
9. No reader-level instrumentation.
Readers delegate to use cases that are already wrapped with withSpan and withCapture at bind time. Adding reader-level spans would create redundant parent spans for every cross-feature query. Use case spans are sufficient for tracing.
Alternatives considered
-
Events for everything (status quo). Rejected for domain queries — events are async and fire-and-forget. You cannot
await bus.publish("auth.check-role")and get an answer back. Forcing queries through the event bus would require request-scoped correlation IDs, reply channels, and timeouts — essentially rebuilding synchronous RPC over an async bus. -
Direct use-case imports across features. Rejected — violates vertical isolation. If blog imports
checkUserRoleUseCasefrom auth, it takes a transitive dependency on auth's repository interfaces, DI symbols, and internal structure. A change inside auth's use case can break blog's compilation. -
Shared query interfaces in
core-shared. Rejected —core-sharedis infrastructure. PuttingIAuthReaderthere means core-shared accumulates feature-specific domain types, which inverts the dependency direction (core depends on feature concepts). -
A standalone
core-protocolspackage. Rejected as premature — adds a new package for what is currently a type-only export. If the number of readers grows beyond 5-6, this can be reconsidered. For now, the owning feature is the natural home. -
Gateways (reader + writer in one interface). Rejected — synchronous cross-feature writes are dangerous. A failure in the target feature's write path would fail the caller's request. Writes should be fire-and-forget (events) so the caller's request path is not coupled to the target's write availability. See rule Q2.
-
Bidirectional readers (allowing cycles). Rejected — cycles indicate wrong feature boundaries. Three resolution strategies exist (UI composition, event for one direction, merge features), making a runtime cycle-breaking mechanism unnecessary. See rule Q3.
-
Rely solely on Payload
relationTo. Rejected as the sole mechanism —relationTogives raw data, not domain-evaluated answers. It also doesn't work in dev-seed/test mode with mock repositories. However,relationToremains the correct choice for raw data joins where no domain logic is needed.
Consequences
Positive:
- Verticals can answer synchronous domain queries for other verticals without violating isolation.
- The manifest's
readsfield makes cross-feature coupling visible, greppable, and agent-readable — same aspublishes/consumesfor events. - Read-only enforcement (
ReadOnly<F>brand +assertReaderPurity) prevents accidental cross-feature mutations. - Cycle detection is structural (compile-time in
bindAll()) — no runtime checks needed. - No new mock infrastructure — existing use case mocks power the reader in dev-seed; inline vitest mocks suffice for consumer tests.
- The pattern is consistent with existing conventions: integration boundary (
integrations/readers/), public contract + private implementation (rule Q1 parallels E1), manifest declaration + conformance check.
Negative:
- Adds a fourth cross-feature coupling mechanism (alongside events, jobs, and realtime). Developers and agents must choose correctly. The decision matrix in section 1 mitigates this.
- Feature binders that expose readers change their return type (from
voidto{ reader: I<Feature>Reader }).bindAll()ordering becomes explicit. This is intentional — it makes the dependency graph visible — but it's a change to existing binder signatures. - The
readsmanifest field andno-undeclared-readerESLint rule are new conformance machinery. Implementation cost is bounded (follows the exact pattern ofpublishes/consumes+no-undeclared-event-publish). - Reader interfaces can grow organically in ways that are hard to audit. Mitigated by the "one reader per feature, grown on demand" rule and the principle that a bloated reader signals a fat vertical.
Implementation notes
- Generator: A
pnpm turbo gen readergenerator should be added to scaffold theintegrations/readers/structure, add the./readerexport topackage.json, and create the interface + implementation + test files. Not required for day one — hand-authoring the first reader is acceptable while the pattern stabilizes. - Existing features: None of the five template features (auth, blog, media, marketing-pages, navigation) currently need readers. The first reader will be created when a product vertical requires a cross-feature domain query. Auth is the most likely candidate (
IAuthReaderfor permission checks). BindContextis unchanged. Readers flow as binder-to-binder parameters (viabindAll()), not throughctx. This keepsBindContextfocused on infrastructure concerns.- Payload
relationTocontinues unchanged. Readers supplement it, they don't replace it. UserelationTofor raw data joins; use readers for domain-evaluated queries.
Out of scope (deferred)
- ESLint rule
no-undeclared-reader. Follows theno-undeclared-event-publishpattern. Deferred until the first reader is exercised. - Contract evolution / versioning for readers. Same as event contracts (ADR-015 §deferred-3) — no migration story for breaking reader interface changes yet.
Planned
pnpm turbo gen readergenerator. Will scaffoldintegrations/readers/+./readerexport subpath + interface + implementation + test. Follows thegen eventPlop pattern with anchor protocol.
Related
- ADR-006 — Vertical feature packages (the isolation model readers operate within)
- ADR-008 — Per-feature DI containers (reader wiring uses the same container model)
- ADR-010 — Turborepo boundaries (feature → feature type imports are allowed; reader contracts are type-only)
- ADR-015 — Cross-feature events and background jobs (readers complement events; rules Q0–Q3 parallel E0/E1/J0)