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.
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.
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)
Symbols
Binding
Resolves to
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.
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, ), ), ); });
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 is optional — present only when @repo/core-events is scaffolded const { config, tracer, logger, bus, queue, realtime, realtimeRegistry } = 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. }
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.
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.
it() blocks, run twice (once per impl)Article entities with overridable defaultsThe mock is reached from two directions. Both are legitimate, neither is "the test version":
- By the DI container at runtime.
BlogModulebindsIArticlesRepositorytoMockArticlesRepositoryat module-load time. Anything resolving that symbol — use cases, controllers, tRPC procedures, the dev server — gets the mock untilbindProductionBlog(ctx: BindProductionContext)swaps it for the real Payload-backed one. See §03. - 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 bybuildSubject. 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 validArticlewith 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.
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); }); });
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); });
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.
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
Domain error classes
One file per error domain (article.ts, auth.ts) plus common.ts for InputParseError.
Pros
- Domain errors carry meaning —
ArticleNotFoundErrorbeats a genericErrorby miles defineErrorMiddlewarematches byinstanceofand translates toTRPCErrorcodes- 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 InputParseErroris duplicated per feature (~6 lines × 5) — by design, but feels redundant- Adding a new error class = update the feature's
procedures.tsmap too
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
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)
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)
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-cmsdep) - Class names without
Payloadprefix — DI swaps mock ↔ real cleanly
Cons
- Per-method
getPayload({ config })is repetitive toDomainmappers 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
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 devjust 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)
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
NotImplementedErrorwhile 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
Factory controllers + presenter
One file per use case. Receives unknown, safeParses, calls use case, runs presenter.
Pros
- Transport-agnostic — same controller works from tRPC, CLI, server actions, cron
- Owns input parsing —
InputParseErroris the controller's responsibility, never the use case's - Co-located
function presentermeans 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+ controllersafeParse) — 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
The address book
Plain object of Symbol.for("blog:I…") keys. One per binding the container holds.
Pros
Symbol.fornamespacing 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)
Default binding registry
ContainerModule with all repository, service, use-case, controller bindings.
Pros
- Declarative — every binding visible in one block; easy to audit
.toDynamicValueis 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
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
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.
Pros
- Decouples Payload config from the feature package — boundary stays clean
- Idempotent (
isBoundguard) — 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
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)
Feature-scoped tRPC procedure
xProcedure = t.procedure.use(defineErrorMiddleware([...])) — owns the feature's error-to-code map.
Pros
- Feature owns its error →
TRPCErrormapping — no central registry, nocore-sharedcoupling - Adding an error class = one tuple in this file; type system guides you
defineErrorMiddlewareincore-sharedis 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_REQUESTtuple is dormant on the tRPC path (tRPC's own zod parse fires first) — feels theatrical
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 —
createCallergoes through the container
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-coregrab-bag - Hooks for the feature's domain logic (revalidation, slugify) are co-located
core-cmsjust 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
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 incore-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
./uisubpath is a fifth public-API entry to maintain per feature
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()inbeforeEachcauses flaky test ordering
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)
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
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
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)
HTTP transaction (auto, @sentry/nextjs)
└── tRPC procedure span (auto, sentry trpc integration)
└── controller span (op="controller", composed at DI bind time)
└── use-case span (op="use-case", composed at DI bind time)
└── repository span (op="repository", inline per method)
└── Payload Local API call (auto, @sentry/node http)
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, SentryLogger, 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— everySentry.init(). CI grep gate.- Replay default-masks all text + inputs + media. Allowlist starts empty.
beforeSendscrubber strips email / password / token / cookie / authorization / ipaddress keys (substring match).beforeSendTransactionscrubber strips PII query params from URLs.setUseraccepts only{ id }. Stripping wrapper warns in dev when other keys passed.- IPv4/IPv6 in event payload string values redacted to
[redacted-ip].
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.