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. }
Contracts & factories.
Two small testing utilities you'll see in every feature: __contracts__/<x>-repository.contract.ts and __factories__/<x>.factory.ts. They look optional — they aren't, once you have more than two implementations of the same interface.
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.