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(config) unbinds the symbol and rebinds it to new ArticlesRepository(config). Use cases and controllers don't notice — they get whatever the symbol currently resolves to.
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 resolved Payload config.
export function bindProductionBlog(config: SanitizedConfig): void { 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(config)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(config) 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); });
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.