§ 01

## The shape of a feature.

Every feature package — `auth`, `blog`, `marketing-pages`, `navigation`, `media` — has the same internal layout. Click any layer to see what lives there and why.

├─ entities/ ├─ models/ ← Zod schemas + types └─ errors/ ← domain errors (set this.name) ├─ application/ ├─ repositories/ ← <x>.repository.interface.ts ├─ services/ ← <x>.service.interface.ts └─ use-cases/ ← factory + xInputSchema + xOutputSchema ├─ infrastructure/ ├─ repositories/ ← <x>.repository.ts (real) │ + <x>.repository.mock.ts └─ services/ ← <x>.service.ts + .mock.ts ├─ interface-adapters/ └─ controllers/ ← factory + safeParse + presenter ├─ di/ ├─ symbols.ts ← inversify Symbol.for(...) keys ├─ module.ts ← ContainerModule with .toDynamicValue ├─ container.ts ← Container + .load(Module) ├─ bind-production.ts ← swaps mocks → real impls at boot └─ bind-dev-seed.ts ← swaps empty mocks → populated mocks ├─ integrations/ ├─ api/ │ ├─ procedures.ts ← xProcedure + defineErrorMiddleware │ └─ router.ts ← xProcedure.input(xInputSchema) └─ cms/ ← Payload collections / globals ├─ ui/ ├─ index.ts ← public surface for queries / components └─ query.ts ← React Query option builders ├─ __factories__/ ← defineFactory<Entity>((seq)=>{...}) ├─ __contracts__/ ← defineContractSuite<IRepo>(...) ├─ __seeds__/ ← buildDev<Entities>() — dev-mode realistic data └─ index.ts ← root: contracts only
§ 02

## A request, step by step.

From a React Query call on the client to Payload's local API and back. Pick a feature, then click a stage — or hit play. The error path branches off at Use case or Repository when a domain error is thrown; the success path runs through the controller's presenter on the way out.

feature
1 / 11
§ 03

## Wiring the container.

Each feature owns one InversifyJS container. Symbols → factory bindings via `.toDynamicValue`. The same symbol resolves to a mock at dev time and to a real Payload-backed impl after `bindProduction*(config)` runs at app boot. Toggle below to see what swaps.

### Resolving blogContainer.get(IGetArticlesController)

mode →

#### Symbols

repo symbol
BLOG_SYMBOLS.IArticlesRepository
use case symbol
BLOG_SYMBOLS.IGetArticlesUseCase
controller symbol
BLOG_SYMBOLS.IGetArticlesController

#### Binding

.to(MockArticlesRepository)
.toDynamicValue((ctx) ⇒ getArticlesUseCase(ctx.container.get(...)))
.toDynamicValue((ctx) ⇒ getArticlesController(ctx.container.get(...)))

#### Resolves to

class · mock
MockArticlesRepository
closure · factory
getArticlesUseCase(repo)
closure · factory
getArticlesController(useCase)

#### Why .toDynamicValue?

A use case is a curried factory: `(deps) => async (input) => result`. It isn't a class, so `.to(SomeClass)` can't construct it. `.toDynamicValue((ctx) => ...)` runs at resolution time, lets the container fetch each dependency, and returns a closure that captures them.

Result: every `container.get(SYMBOL)` call hands you a fully-wired async function. Tests don't need any of this — they construct mocks and pass them in directly.

#### Two binding modes, one symbol.

The `BlogModule` binds `IArticlesRepository` to `MockArticlesRepository` by default — useful at dev/test time. At app boot, `bindProductionBlog(ctx: BindProductionContext)` unbinds the symbol and rebinds it to `new ArticlesRepository(ctx.config, ctx.tracer, ctx.logger)`. Use cases and controllers don't notice — they get whatever the symbol currently resolves to. The `ctx` object is built once by the app aggregator and passed to all feature binders.

This is also why the boundary stays clean: features don't import `core-cms`; the app passes the Payload config in.

module · default bindings

### blog/di/module.ts

One module, one container. Loaded once at `blogContainer.load(BlogModule)`.

    export const BlogModule = new ContainerModule((bind) => {
      bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
        .to(MockArticlesRepository);                 // default

      bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase)
        .toDynamicValue((ctx) =>
          getArticlesUseCase(
            ctx.container.get<IArticlesRepository>(
              BLOG_SYMBOLS.IArticlesRepository,
            ),
          ),
        );

      bind<IGetArticlesController>(BLOG_SYMBOLS.IGetArticlesController)
        .toDynamicValue((ctx) =>
          getArticlesController(
            ctx.container.get<IGetArticlesUseCase>(
              BLOG_SYMBOLS.IGetArticlesUseCase,
            ),
          ),
        );
    });

app boot · production override

### blog/di/bind-production.ts

Called from each app's bootstrap (`apps/web-next/src/server/bind-production.ts`) with the `ctx` object built once by the aggregator. `BindProductionContext` is imported from `@repo/core-shared/di`.

    export function bindProductionBlog(ctx: BindProductionContext): void {
      // bus, realtime, realtimeRegistry, auditLog are optional — present only when the corresponding optional package is scaffolded
      const { config, tracer, logger, bus, queue, realtime, realtimeRegistry, auditLog } = ctx;
      if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
        blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
      }
      blogContainer
        .bind(BLOG_SYMBOLS.IArticlesRepository)
        .toConstantValue(new ArticlesRepository(config));
      // Use cases + controllers stay untouched.
      // They'll resolve through the new repo automatically.
    }

§ 04

## Mocks, contracts & factories.

Three artifacts that sit near tests, at different distances from runtime. The mock repository is a real implementation of the interface — runtime code (DI, dev mode, storybook) reaches it. The contract is a test suite that runs against any implementation of the repo interface, mock or real. The factory builds valid entity values. The relationship between them is the interesting bit.

where the boundaries are

### Same neighborhood, different reach.

The mock is a test artifact, but it's also more than that — it's a real implementation of the repository interface, and runtime code reaches it directly. DI binds it as the default; dev mode runs against it when Payload isn't booted; storybook stories that need data resolve to it. The contract and factory are only reached from test files. That difference in reach is what determines where each lives.

So the mock sits in `infrastructure/repositories/` next to the real impl — they're sibling implementations of the same interface, both legitimate citizens of the runtime layer. The contract and factory live under `__`-prefixed directories that nothing outside `*.test.ts` ever imports from.

application/repositories/
IArticlesRepository
interface — defines the shape every implementation must satisfy
↓ implemented by both ↓
infrastructure/repositories/
MockArticlesRepository
in-memory · default DI binding · used by dev mode & unit tests
infrastructure/repositories/
ArticlesRepository
Payload-backed · production binding · constructed at app boot
↓ both tested by ↓
__contracts__/
articlesRepositoryContract
portable test suite — same `it()` blocks, run twice (once per impl)
↓ consumes seed data from ↓
__factories__/
articleFactory
data builder — produces valid `Article` entities with overridable defaults

The mock is reached from two directions. Both are legitimate, neither is "the test version":

1. By the DI container at runtime. `BlogModule` binds `IArticlesRepository` to `MockArticlesRepository` at module-load time. Anything resolving that symbol — use cases, controllers, tRPC procedures, the dev server — gets the mock until `bindProductionBlog(ctx: BindProductionContext)` swaps it for the real Payload-backed one. See §03.
2. By tests, via direct construction. Unit tests skip the container entirely. They construct the mock with `new MockArticlesRepository()` and pass it directly into the use-case factory function. Same class, different consumer — just a closure with a fake repo.

The contract and factory are reached from one direction only — tests. They never appear in runtime imports. Their roles:

- Contract = a single suite of `it()` blocks parameterized by `buildSubject`. Run it against the mock, run it against the real Payload-backed impl. If they diverge — your mock is lying about Payload's behavior, and you'd never catch it without the contract. This is its whole reason to exist: it tests the mock so you can trust it, alongside testing the real impl.
- Factory = a sequence-counter builder. `articleFactory.build({ slug: "x" })` hands you a valid `Article` with sensible defaults; you only override the fields the test cares about. Used by the contract and by every use-case / controller test that needs entity values without writing 8 lines of inline fixtures.

show: the mock as DI binding (in module.ts)

This is from `packages/blog/src/di/module.ts` — the very first binding in the module is the mock. Everything downstream (use cases, controllers) resolves through this default. `bindProductionBlog(ctx: BindProductionContext)` later replaces only this one line at app boot — use case + controller bindings stay put.

    export const BlogModule = new ContainerModule((bind) => {
      // 1) Mock is the DEFAULT binding for the repo symbol.
      //    Dev server, unit tests, storybook all resolve to this.
      bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository)
        .to(MockArticlesRepository);

      // 2) Use cases consume IArticlesRepository — they don't know or
      //    care which impl they got. Same factory function in either mode.
      bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase)
        .toDynamicValue((ctx) =>
          getArticlesUseCase(
            ctx.container.get<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository),
          ),
        );
      // ... + 5 more bindings, all the same shape.
    });

show: the mock as direct test fake (no container)

Use-case + controller tests skip DI entirely. They construct the mock and pass it as the first argument to the use-case factory, then call the resulting closure with the input. Three lines of setup, then assertions.

    it("filters by status", async () => {
      // Construct the mock directly — no DI container, no rebinding.
      const repo = new MockArticlesRepository();

      // Use the factory to seed valid entities (only override what we care about).
      articleFactory.reset();
      await repo.createArticle(articleFactory.build({ status: "draft" }));
      await repo.createArticle(articleFactory.build({ status: "published" }));

      // Inject the mock into the use-case factory; call the resulting closure.
      const useCase = getArticlesUseCase(repo);
      const result = await useCase({ status: "published" });

      expect(result).toHaveLength(1);
    });

show: the contract testing both impls (the proof-of-parity bit)

Two tiny test files, one shared suite. If the suite ever fails on the real impl but passes on the mock — your mock is lying about Payload's behavior and you'd ship a bug. The factory is doing real work here too: every `it()` in the suite uses `articleFactory.build(...)` for seed data, so the assertions stay readable.

    describe("MockArticlesRepository", () => {
      articlesRepositoryContract.run(async () => new MockArticlesRepository());
    });

    // articles.repository.test.ts (Payload-backed) — same suite, real impl
    vi.mock("payload", () => ({ getPayload: vi.fn() }));

    describe("ArticlesRepository (Payload)", () => {
      articlesRepositoryContract.run(async () => {
        const stub = buildPayloadStub();
        (getPayload as Mock).mockResolvedValue(stub);
        return new ArticlesRepository(stubPayloadConfig);
      });
    });

When you run `pnpm test --filter @repo/blog`, the contract's twelve `it()` blocks run twice — once per implementation. Twenty-four assertions for the price of writing twelve.

__contracts__/

### The behavioral contract.

A contract suite is a portable set of tests that asserts every implementation of a repository interface behaves the same way. You write it once, run it against the mock, run it again against the real Payload-backed impl. If they diverge — bug.

The suite takes a `buildSubject` callback so each implementation can supply its own setup (e.g., the Payload impl needs to mock `getPayload()` first; the in-memory mock just constructs).

show: defining the suite

    export const articlesRepositoryContract =
      defineContractSuite<IArticlesRepository>(
        "IArticlesRepository",
        ({ buildSubject }) => {
          let repo: IArticlesRepository;

          beforeEach(async () => {
            articleFactory.reset();
            repo = await buildSubject();
          });

          it("createArticle returns an article with the correct fields", async () => {
            const seed = articleFactory.build({ title: "Hello World" });
            const created = await repo.createArticle(seed);
            expect(typeof created.id).toBe("string");
            expect(created.title).toBe("Hello World");
          });

          it("getArticles filters by status", async () => {
            await repo.createArticle(articleFactory.build({ status: "draft" }));
            await repo.createArticle(articleFactory.build({ status: "published" }));
            const drafts = await repo.getArticles({ status: "draft" });
            expect(drafts).toHaveLength(1);
          });

          // ... ten more `it` cases covering every method on IArticlesRepository
        },
      );

show: running it against both impls

    describe("MockArticlesRepository", () => {
      articlesRepositoryContract.run(async () => new MockArticlesRepository());
    });

    // articles.repository.test.ts (Payload-backed)
    vi.mock("payload", () => ({ getPayload: vi.fn() }));

    describe("ArticlesRepository (Payload)", () => {
      articlesRepositoryContract.run(async () => {
        const stub = buildPayloadStub();
        (getPayload as Mock).mockResolvedValue(stub);
        return new ArticlesRepository(stubPayloadConfig);
      });
    });

__factories__/

### The data factory.

A factory is a sequence-counter-driven builder for an entity. `articleFactory.build({ title: "X" })` hands you a complete, valid `Article` with sensible defaults — only the fields you specify get overridden. Call `.reset()` in `beforeEach` to keep ids deterministic.

The point: tests stop drowning in inline fixtures (`{ id: "abc", title: "...", slug: "...", content: null, status: "draft", authorId: "u1", createdAt: new Date(...), updatedAt: new Date(...) }`) and assert only the fields they care about.

show: defining a factory

    import { defineFactory } from "@repo/core-testing/factory";
    import type { Article } from "../entities/models/article";

    export const articleFactory = defineFactory<Article>(({ sequence }) => ({
      id: `article-${sequence}`,
      title: `Article ${sequence}`,
      slug: `article-${sequence}`,
      content: null,
      status: "draft",
      authorId: "user-1",
      createdAt: new Date("2026-01-01T00:00:00Z"),
      updatedAt: new Date("2026-01-01T00:00:00Z"),
    }));

show: using it in a test

    it("filters by status", async () => {
      const repo = new MockArticlesRepository();
      articleFactory.reset();
      await repo.createArticle(articleFactory.build({ status: "draft" }));
      await repo.createArticle(articleFactory.build({ status: "published" }));

      const useCase = getArticlesUseCase(repo);
      const result = await useCase({ status: "published" });

      expect(result).toHaveLength(1);
    });

§ 05

## Tradeoffs by part.

Every layer in the feature anatomy gives you something and costs you something. This is the honest accounting — what each folder or file buys, what it asks in return. Read this when you're deciding whether to add a piece, not all at once.

entities/models/

### Entity Zod schemas + types

One file per entity (`article.ts`, `user.ts`): a Zod schema and the inferred TypeScript type.

##### Pros

- One source of truth: schema and type from a single declaration via `z.infer`
- Runtime validation available anywhere the schema is imported (output schemas, parser-transforms)
- Pure-domain — zero framework knowledge, infinitely portable
- Test factories build against this same shape, can't drift

##### Cons

- Adds Zod as a feature-level dep where a plain TS interface would suffice
- Schema can drift from Payload's actual collection shape — must keep in sync manually
- Overkill for entities that never get parsed at runtime

entities/errors/

### Domain error classes

One file per error domain (`article.ts`, `auth.ts`) plus `common.ts` for `InputParseError`.

##### Pros

- Domain errors carry meaning — `ArticleNotFoundError` beats a generic `Error` by miles
- `defineErrorMiddleware` matches by `instanceof` and translates to `TRPCError` codes
- Per-feature ownership — auth's errors don't leak into blog

##### Cons

- Every constructor must set `this.name` (R6) — easy to forget, was the systemic Plan-9 fix-up
- `InputParseError` is duplicated per feature (~6 lines × 5) — by design, but feels redundant
- Adding a new error class = update the feature's `procedures.ts` map too

application/repositories/

### Repository interfaces

`<x>.repository.interface.ts` — TypeScript interface, no implementation, no Zod.

##### Pros

- Use cases depend on the contract, not a concrete class — testable, swappable
- Mock and real impl share the interface — TypeScript catches drift at compile time
- Plain TypeScript — no extra deps, no runtime cost

##### Cons

- One more file per repo (interface + real + mock = 3 minimum)
- Method names get written twice (interface + each impl) — refactors touch both
- Wider repo signatures vs narrower use-case schemas can feel duplicative

application/services/

### Service interfaces

`<x>.service.interface.ts` — interface for non-data-access boundaries (auth, email, …).

##### Pros

- Same testability win as repos — stub the boundary, inject the stub
- Lets stateful behavior (sessions, password hashing) sit behind a clean contract
- Use cases don't import auth lib internals — they call methods on an interface

##### Cons

- Easy to over-create services for thin one-method wrappers
- The "service" name is fuzzy — can become a junk drawer of "anything that's not a repo"
- Most features don't need any (only auth has one today)

application/use-cases/

### Factory-function use cases

One file per verb-noun (`get-articles.use-case.ts`): input + output schemas + factory.

##### Pros

- Single source of truth for I/O contracts (R1) — schema lives here, controllers and routers import
- `xOutputSchema.parse(...)` at the end of the body catches malformed repo returns at the layer that owns the contract
- Factory function = trivially testable; tests construct mocks and inject directly
- One concern per file; clear single responsibility

##### Cons

- ~30 lines of file overhead per use case (input + output + types + factory)
- Identity output schemas (`z.array(articleSchema)`) feel ceremonial when they don't add validation
- Many files per feature (blog has 3, media has 3, marketing-pages has 2)
- `.parse()` on every call has measurable cost on hot paths (negligible in practice)

infrastructure/repositories/<x>.repository.ts

### Real (Payload-backed) repository

Constructor takes `SanitizedConfig`, methods call `getPayload({ config })`, map to domain.

##### Pros

- All Payload knowledge lives in one place per repo — easy to swap CMS later
- Constructor injection keeps the feature boundary clean (no `core-cms` dep)
- Class names without `Payload` prefix — DI swaps mock ↔ real cleanly

##### Cons

- Per-method `getPayload({ config })` is repetitive
- `toDomain` mappers are easy to forget for new fields → silent shape drift
- Tests need `vi.mock("payload")` + Payload stub setup — more ceremony than testing the mock impl

infrastructure/repositories/<x>.repository.mock.ts

### Mock repository (sibling of real)

In-memory implementation. The default DI binding; also injected directly in unit tests.

##### Pros

- Dev mode runs without Payload booted — `pnpm dev` just works
- Direct test injection — no DI ceremony for use-case / controller tests
- Same interface as real impl; contract suite proves behavioral parity
- Fast and deterministic — no setup, no fixtures, no I/O

##### Cons

- Without the contract suite, mock can silently diverge from real (e.g. ID-uniqueness assumptions)
- Doubles the repo file count (one real + one mock per repo)
- Easy to give the mock "extra" behavior the real impl can't match (false confidence)

infrastructure/services/

### Real + mock services

Same dual-impl pattern as repositories. Auth has one (real `AuthenticationService` + mock).

##### Pros

- Lets stateful boundary code (session creation, password hashing) be unit-tested with a fake
- Real impl can defer hard parts as `NotImplementedError` while the mock fully works (auth's session methods do this today)

##### Cons

- Adds a directory tree most features don't need
- "Service" abstraction can hide what's actually being mocked — repos are clearer
- Deferred-real-impl pattern is honest but technical-debt-shaped

interface-adapters/controllers/

### Factory controllers + presenter

One file per use case. Receives `unknown`, `safeParse`s, calls use case, runs presenter.

##### Pros

- Transport-agnostic — same controller works from tRPC, CLI, server actions, cron
- Owns input parsing — `InputParseError` is the controller's responsibility, never the use case's
- Co-located `function presenter` means view-shape transforms live next to the wire
- One controller per use case — clear single responsibility, easy to test

##### Cons

- Schema runs twice on the tRPC path (procedure `.input` + controller `safeParse`) — defense in depth has a cost
- Identity presenters feel ceremonial when no transform is needed (R11 always-present rule)
- Per-use-case files multiply — blog has 3, marketing-pages has 2

di/symbols.ts

### The address book

Plain object of `Symbol.for("blog:I…")` keys. One per binding the container holds.

##### Pros

- `Symbol.for` namespacing prevents cross-feature collisions
- Type-erased keys let the container index without forcing eager class imports
- Const object — every binding has a compile-time-checked key

##### Cons

- Adding a use case = update three places: factory, symbols, module binding
- Symbols carry no type info at runtime — bind/get must agree on the type parameter (footgun)

di/module.ts

### Default binding registry

`ContainerModule` with all repository, service, use-case, controller bindings.

##### Pros

- Declarative — every binding visible in one block; easy to audit
- `.toDynamicValue` is what makes factory functions work as DI bindings
- Loaded once at module construction; nothing runs per-request

##### Cons

- Verbose — each `.toDynamicValue((ctx) => factoryFn(ctx.container.get(...)))` repeats boilerplate
- Imports every concrete class + factory in the feature — large surface area in one file

di/container.ts

### The singleton

Three lines: `reflect-metadata`, `new Container({ defaultScope: "Singleton" })`, `load(Module)`.

##### Pros

- Singleton scope = automatic caching; subsequent `.get()` calls reuse the closure
- One-line file — almost no maintenance
- Per-feature container = vertical isolation; no cross-feature DI coupling (ADR-008)

##### Cons

- Module-level singleton = global state; tests must `unbindAll()` + reload to start fresh
- `import "reflect-metadata"` is a side-effect import — easy to forget when scaffolding new files

di/bind-production.ts

### Production binder

`bindProduction<F>(ctx: BindProductionContext)` — unbinds the mock, rebinds the real Payload-backed impl. The `ctx` arg carries required fields (`tracer`, `logger`, `config`) and optional cross-cutting deps (`queue`). Event bus (`bus`) is also optional — present only when `@repo/core-events` is scaffolded via `pnpm turbo gen core-package events`; absent, `bus?.subscribe/publish` calls are no-ops. Realtime deps (`realtime`, `realtimeRegistry`) are also optional — present only when `@repo/core-realtime` is scaffolded via `pnpm turbo gen core-package realtime`. Audit log (`auditLog`) is also optional — present only when `@repo/core-audit` is scaffolded via `pnpm turbo gen core-package audit`; absent, `ctx.auditLog?.record(entry)` calls are no-ops. When present, `auditLog` is a `TraceIdEnrichingAuditLog` that auto-populates `AuditEntry.correlationId` from the active OTel span.

##### Pros

- Decouples Payload config from the feature package — boundary stays clean
- Idempotent (`isBound` guard) — safe to call multiple times
- Only the repo binding swaps; use cases + controllers stay put and pick up the new repo automatically

##### Cons

- Easy to forget to call when wiring a new app — silent fallback to mock in production
- One per feature × app — boilerplate compounds with feature count

di/bind-dev-seed.ts

### Dev-seed binder

`bindDevSeed<F>(ctx: BindContext)` — unbinds the empty mock, rebinds a populated mock (post-Plan-9).

##### Pros

- Dev mode shows realistic data without Payload running — design review, storybook, offline work all just work
- Reuses the same factory the tests use — no separate fixture system
- Symmetric with `bind-production` — one mental model, two binders

##### Cons

- Yet another file per feature
- Seed data drifts from real Payload shape over time — needs occasional refresh
- Fourth place to update when entity schemas change (after schema, factory, repo)

integrations/api/procedures.ts

### Feature-scoped tRPC procedure

`xProcedure = t.procedure.use(defineErrorMiddleware([...]))` — owns the feature's error-to-code map.

##### Pros

- Feature owns its error → `TRPCError` mapping — no central registry, no `core-shared` coupling
- Adding an error class = one tuple in this file; type system guides you
- `defineErrorMiddleware` in `core-shared` is plumbing only — boundary stays clean

##### Cons

- Adds a fifth file to `integrations/api/` — a feature with two procedures has the same overhead as one with ten
- The `InputParseError → BAD_REQUEST` tuple is dormant on the tRPC path (tRPC's own zod parse fires first) — feels theatrical

integrations/api/router.ts

### tRPC router slice

One file per feature, composed into `core-api`'s `appRouter` via the `./api` export.

##### Pros

- `xProcedure.input(xInputSchema)` — schemas imported from the use-case file, never redefined
- One slice per feature — easy to add or remove a feature from the API
- Container is the only thing the router knows about — controllers are resolved, not imported

##### Cons

- Router files grow proportional to procedure count — blog already has three
- Routers can't be tested without DI involvement — `createCaller` goes through the container

integrations/cms/

### Payload collections + globals

Collection / global definitions exposed via the `./cms` export, composed into `core-cms`.

##### Pros

- Each collection lives with its feature — no central `cms-core` grab-bag
- Hooks for the feature's domain logic (revalidation, slugify) are co-located
- `core-cms` just composes; features stay independently versionable

##### Cons

- Coupling to Payload's API surface — swapping CMS later is non-trivial
- Collection schema can drift from the entity Zod schema — two places to keep in sync

ui/

### Frontend public surface

`ui/index.ts` exports query builders + components. Apps import via `@repo/<feature>/ui`.

##### Pros

- Apps depend on the feature's UI surface explicitly — clear separation from contracts
- Query builders sit next to the data they query — refactoring is local
- Components for feature-specific UI (e.g. `ArticleCard`) live with the feature, not in `core-ui` (optional package — scaffold via `pnpm turbo gen core-package ui`)

##### Cons

- Features without UI today (auth, media) still need a placeholder `export {}`
- React Query API surface coupling — swapping client lib touches every feature
- The `./ui` subpath is a fifth public-API entry to maintain per feature

__factories__/

### Test data factories

One factory per entity. Sequence-counter defaults; `.build({ overrides })`; `.reset()` in `beforeEach`.

##### Pros

- Tests stop drowning in inline fixtures — only override what the assertion cares about
- Sequence-driven defaults = deterministic IDs across runs
- Shared between unit tests, contract suites, and `__seeds__/` — one source of "valid entity"

##### Cons

- Factory defaults can become outdated as entity schema evolves — Zod tightens, factory still hands out old shape, tests pass but production fails
- Forgetting `.reset()` in `beforeEach` causes flaky test ordering

__contracts__/

### Repository contract suite

A portable test suite parameterized by `buildSubject`. Run against mock + real impl.

##### Pros

- Catches mock / real divergence at unit-test time, not in production
- The behavioral contract is a literal artifact, not folklore
- Twelve `it()` blocks run twice — twenty-four assertions for the price of writing twelve

##### Cons

- ~50 lines of test boilerplate per repository
- Adding a method to the interface means writing the contract test before either impl can pass
- Easy to over-specify and lock implementation details (e.g. assert exact ID format the mock happens to produce)

__seeds__/

### Dev-seed data

`buildDev<Entities>()` — uses the feature's factory; consumed by `bind-dev-seed`.

##### Pros

- App-level realistic data without Payload running — storybook, design review, offline dev
- Lazy function = side-effect-free at module load (factory sequence not advanced on import)
- Reuses factory defaults — no parallel fixture system

##### Cons

- The `__` prefix borrows from the test convention but this folder is reachable from runtime via DI — slightly mislabeled
- Seed entities can rot — refreshes when entity schema or domain model changes
- Yet another place where realistic data lives — tests, factories, seeds, real Payload all need to agree

src/index.ts (root)

### Public contract surface

The feature's `.` export — types, errors, schemas, IUseCase aliases, router type, constants.

##### Pros

- Single file lists everything the feature exposes — easy to audit, easy to enforce
- Clean split from `./ui`: contracts here, UI artifacts there
- Type aliases (`IXUseCase`, `IXController`) decouple consumers from the impl

##### Cons

- Maintenance burden — every new schema / error / type needs an explicit re-export
- Easy to forget; downstream consumers can't reach a symbol that wasn't re-exported

§ 06

## Tracing & error capture.

Every request produces a nested span tree: tRPC procedure → controller → use case → repository → Payload op. Errors are captured at the throw site closest to the cause, never at the boundary that translates them.

### The trace tree (one tRPC request)

Substrate: OpenTelemetry SDK (ADR-017). Sentry is the exporter via `@sentry/opentelemetry`. Auto-instrumentations cover HTTP, undici, and pg; feature code emits via `ITracer` / `ILogger` interfaces only.

    HTTP transaction               (auto, OTel HttpInstrumentation)
    └── tRPC procedure span        (auto, OTel + Sentry tRPC integration)
        └── controller span         (op="controller", composed at DI bind time via OtelTracer)
            └── use-case span       (op="use-case",  composed at DI bind time via OtelTracer)
                └── repository span (op="repository", inline per method via ITracer)
                    └── Payload Local API call (auto, OTel PgInstrumentation / UndiciInstrumentation)

### Where instrumentation actually lives

Two ways spans + captures get attached. Inline means the call appears in the layer's own body. Composed-in means a higher-order wrapper applied at DI bind time — the body stays vendor-clean.

Layer | Span | Capture | How
--- | --- | --- | ---
Use case body | — | — | Composed: `withSpan(withCapture(useCase(deps)))` in `bind-production.ts`
Controller body | — | — | Composed: `withSpan(withCapture(controller(uc)))` in `bind-production.ts`
Repository (real) | Inline per method | Inline in `catch` | `this.tracer.startSpan(...)` + `this.logger.captureException(...)`
Repository (mock) | Inline per method | — | Span shape parity with real; mocks don't originate infra errors
tRPC procedure | Auto (SDK) | — | Sentry's tRPC integration — no code in this repo
`defineErrorMiddleware` | — | — | Maps domain errors → TRPCError. Never captures (R44 boundary)

Verifiable: `grep -rn "this.tracer\|this.logger" packages/*/src` returns hits only in `infrastructure/repositories/*.repository.ts` and `*.repository.mock.ts`. Use case and controller bodies have zero matches. `withSpan` / `withCapture` appear only in `di/bind-*.ts` files.

### The wrapper sandwich (one feature, in `bind-production.ts`)

    // Repository — inline, per public method
    class ArticlesRepository {
      async getArticles(input) {
        return this.tracer.startSpan(
          { name: "articles.getArticles", op: "repository", attributes: { /* ... */ } },
          async (span) => {
            try {
              const result = await /* payload op */;
              span.setAttribute("count", result.length);
              return result;
            } catch (err) {
              this.logger.captureException(err, {
                tags: { feature: "blog", repo: "articles", method: "getArticles" },
              });
              span.setStatus("error", String(err));
              throw err;
            }
          },
        );
      }
    }

    // Use cases + controllers — composed at bind time, body stays clean
    const wrappedUC = withSpan(
      tracer, { name: "blog.getArticles", op: "use-case" },
      withCapture(
        logger, { feature: "blog", layer: "use-case", name: "blog.getArticles" },
        getArticlesUseCase(repo),
      ),
    );
    const wrappedCtrl = withSpan(
      tracer, { name: "blog.getArticles", op: "controller" },
      withCapture(
        logger, { feature: "blog", layer: "controller", name: "blog.getArticles" },
        getArticlesController(wrappedUC),
      ),
    );

Order matters. `withSpan` is outermost so the errored span's timing reflects the captured-and-rethrown failure. `withCapture` is between span and factory so the error is captured before the span closes with error status.

### Capture rules (where `captureException` fires)

Layer | Captures | Doesn't capture
--- | --- | ---
Repository | Infra / Payload errors that originate here | Bubbled errors (already captured downstream)
Use case | Business-rule violations originated in this body (e.g. `AuthenticationError`) and output-schema validation failures | Errors from repos — flag is set, `withCapture` bails
Controller | `InputParseError` from `safeParse` failure | Errors from use cases — flag is set, `withCapture` bails
`defineErrorMiddleware` | Nothing — maps domain → TRPCError only | —

### Double-report guard

Each error gets a non-enumerable `__sentryReported` flag the first time it's captured. `withCapture`, `OtelLogger`, and `RecordingLogger` all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger exactly once, with the inner-most layer's tags. Helper lives in `core-shared/instrumentation/reported-flag.ts`.

### PII rules (R31–R38, non-negotiable)

- `sendDefaultPii: false` — every `Sentry.init()`. CI grep gate.
- Replay default-masks all text + inputs + media. Allowlist starts empty.
- Server-side: `PiiScrubSpanProcessor` + `PiiScrubLogRecordProcessor` run FIRST in the OTel processor chain — attribute-key substring match strips email / password / token / cookie / authorization / ipaddress keys before the Sentry exporter sees the data (R32, R33, ADR-017 §7).
- Browser-side: `beforeSend` / `beforeSendTransaction` hooks in `init-client*.ts` strip the same PII keys (browser does not use the OTel pipeline).
- `setUser` accepts only `{ id }`. No email/username (R36).
- IPv4/IPv6 redacted to `[redacted-ip]` in browser-side scrubbers.

§ 07

## Do we need them?

### Short answer: yes, both.
Long answer below.

Contracts earn their keep the day a real implementation drifts from its mock — when a Payload field name changes, when a return shape mutates, when null vs undefined gets blurred. The contract suite catches the divergence at unit-test time instead of in production. Cost: ~50 lines per repo. Payoff: every behavioral guarantee gets tested twice (mock + real) for free.

Factories earn theirs by the third test. Inline fixtures grow to a noisy 8–10 lines that obscure what the test is actually checking. `articleFactory.build({ slug: "x" })` says "I need a valid article and I only care about the slug." Nothing else.

Honest tradeoff: small upfront cost (one factory + one contract per feature). Large compounding payoff once you have ≥3 tests touching the entity, or any time you add a second impl behind the same interface. They are not optional ceremony — they are the thing that lets you trust your mocks.

360 testsacross 15 suites · contracts run 2× per repo
R25 + R26output-validation + error-mapping (Plan 9)
defineFactory · defineContractSuiteboth live in @repo/core-testing