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. 04Contracts & 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

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.

__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