template-vertical / architecture / explainer 2026-05-06 · post-Plan-9

A guided tour
of one feature's
data flow.

An internal explainer for the post-Plan-9 architecture — written for the engineer who built it and just wants the mental model in one place. Click through the request flow, flip the DI binding mode, swap features. Real code from this repo, not a tutorial.

Contents
  1. 01Feature anatomy
  2. 02Request flow
  3. 03Dependency injection
  4. 04Mocks, contracts & factories
  5. 05The verdict
§ 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 ├─ 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>(...) └─ 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(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.

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 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.
}
§ 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(config) 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(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.

__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

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