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 things in every feature live near tests but play different roles. The mock repository is a real implementation of the repository interface — it's also the default DI binding. The contract is a portable test suite that runs against any implementation. The factory is a builder for valid entity values. The mock isn't only a test thing; that's the part that surprises people.
Where each lives, and why.
The mock is the surprising one. People assume it lives in __mocks__/ because tests use it — but the DI container needs it as the default binding at runtime, and reaching into __mocks__/ from production code crosses a boundary. So the mock lives next to the real implementation, in infrastructure/repositories/. They're siblings.
it() blocks, run twice (once per impl)Article entities with overridable defaultsThe mock has two jobs.
- Default DI binding.
BlogModulebindsIArticlesRepositorytoMockArticlesRepositoryat module-load time. Anything resolving that symbol — use cases, controllers, the whole chain — gets the mock untilbindProductionBlog(config)swaps it for the real Payload-backed one. See §03. - Test fake (direct injection). Unit tests skip the container entirely. They construct the mock with
new MockArticlesRepository()and pass it directly into the use-case factory function. No DI involved — just a closure with a fake repo.
The contract and factory are pure test ergonomics — they only show up in *.test.ts files. Both live under __-prefixed directories that signal "test territory; not part of the public surface; not imported by runtime code." Their roles:
- Contract = a single suite of
it()blocks parameterized bybuildSubject. Run it against the mock, run it against the real impl. If they diverge — your mock is lying to you and you'd never have caught it without the contract. - Factory = a sequence-counter builder.
articleFactory.build({ slug: "x" })hands you a validArticlewith sensible defaults; you only override the fields the test actually cares about. Used by the contract and by every use-case / controller test.
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.