# 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: 1. **Permission checks.** Blog's `createArticle` needs to verify the author has the "editor" role. The raw user record is available via Payload's `relationTo`, but evaluating "does role X grant permission Y in context Z?" is domain logic that belongs to the auth vertical. 2. **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. 3. **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 `IReader` from a `./reader` subpath. The implementation class (`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: false` in the feature manifest. Enforced by `ReadOnly` TypeScript brand at compile time and `assertReaderPurity` at 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 `IReader` 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. ```typescript // packages/auth/src/infrastructure/readers/auth.reader.ts (INTERNAL) export class AuthReader implements IAuthReader { constructor( private checkRole: ReadOnly, private getUser: ReadOnly, ) {} async hasRole(userId: string, role: string): Promise { return this.checkRole({ userId, role }); } async exists(userId: string): Promise { 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//src/ integrations/ api/ # outward: HTTP consumers cms/ # outward: Payload admin readers/ # outward: other verticals .reader.interface.ts # IFeatureReader (PUBLIC) .reader.ts # FeatureReader (INTERNAL) .reader.test.ts index.ts # exports type { IFeatureReader } only ``` The `package.json` exports map gains a `./reader` entry: ```json { "./reader": "./src/integrations/readers/index.ts" } ``` **6. Wiring: binder returns reader, `bindAll()` threads it to consumers.** Feature binders that expose a reader return it: ```typescript // bindProductionAuth(ctx) returns { reader: IAuthReader } const authResult = bindProductionAuth(ctx); bindProductionBlog(ctx, { authReader: authResult.reader }); ``` Consuming binders accept readers as a second parameter alongside `ctx`: ```typescript 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: [""]` per use case.** The feature manifest declares cross-feature read dependencies: ```typescript 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 declare `reads`. (Parallel to `no-undeclared-event-publish`.) - **Boot assertion `assertReaderPurity`:** Every use case wired into a reader is declared `mutates: false` in the manifest. - **Boot assertion `assertFeatureConformance`:** Every `reads` entry has a corresponding reader injected into the binder. - **`pnpm conformance`:** Cross-feature reader closure — every `reads: ["auth"]` resolves to a feature that exports `./reader`. **8. Read-only enforcement via `ReadOnly` brand.** A new branded type prevents mutating use cases from being wired into readers at compile time: ```typescript type ReadOnly = 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 `checkUserRoleUseCase` from 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-shared` is infrastructure. Putting `IAuthReader` there means core-shared accumulates feature-specific domain types, which inverts the dependency direction (core depends on feature concepts). - **A standalone `core-protocols` package.** 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 — `relationTo` gives raw data, not domain-evaluated answers. It also doesn't work in dev-seed/test mode with mock repositories. However, `relationTo` remains 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 `reads` field makes cross-feature coupling visible, greppable, and agent-readable — same as `publishes`/`consumes` for events. - Read-only enforcement (`ReadOnly` 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 `void` to `{ reader: IReader }`). `bindAll()` ordering becomes explicit. This is intentional — it makes the dependency graph visible — but it's a change to existing binder signatures. - The `reads` manifest field and `no-undeclared-reader` ESLint rule are new conformance machinery. Implementation cost is bounded (follows the exact pattern of `publishes`/`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 reader` generator should be added to scaffold the `integrations/readers/` structure, add the `./reader` export to `package.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 (`IAuthReader` for permission checks). - **`BindContext` is unchanged.** Readers flow as binder-to-binder parameters (via `bindAll()`), not through `ctx`. This keeps `BindContext` focused on infrastructure concerns. - **Payload `relationTo` continues unchanged.** Readers supplement it, they don't replace it. Use `relationTo` for raw data joins; use readers for domain-evaluated queries. ## Out of scope (deferred) 1. **ESLint rule `no-undeclared-reader`.** Follows the `no-undeclared-event-publish` pattern. Deferred until the first reader is exercised. 2. **Contract evolution / versioning for readers.** Same as event contracts (ADR-015 §deferred-3) — no migration story for breaking reader interface changes yet. ## Planned 1. **`pnpm turbo gen reader` generator.** Will scaffold `integrations/readers/` + `./reader` export subpath + interface + implementation + test. Follows the `gen event` Plop 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)