From 1b7e5eac5007750abfe8a1f8c44a3f940103aaa7 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 17:57:57 +0200 Subject: [PATCH] docs(arch): add interactive data-flow explainer page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single-file HTML at docs/architecture/data-flow-explainer.html. Self-contained — Google Fonts (Fraunces + JetBrains Mono) is the only external resource; all interactivity is vanilla JS, all diagrams are inline SVG/CSS. Five sections: 01. Feature anatomy — clickable folder tree, layer detail card swaps to explain entities / application / infrastructure / interface- adapters / di / integrations / ui / __factories__ / __contracts__ / public surface. 02. Request flow — step-through pipeline (12 stages, including the success/error fork) with prev/next/play and a feature picker (auth, blog, marketing-pages, navigation, media). Each feature swaps real code snippets, the procedures.ts error map, and the use-case/controller pattern. 03. Dependency injection — interactive DI canvas with default/prod toggle so the user sees the same symbol resolving to mock vs real Payload-backed impl. Real module.ts and bind-production.ts code blocks below. 04. Contracts and factories — expandable sections with the actual defineContractSuite + defineFactory code from packages/blog. 05. Verdict — short answer (yes), tradeoffs, badge row. Editorial aesthetic: cream paper, deep ink, oxblood accent, Fraunces display + JetBrains Mono code. Subtle paper noise + ruled-line bg. No CDN scripts beyond fonts. --- docs/architecture/data-flow-explainer.html | 2117 ++++++++++++++++++++ 1 file changed, 2117 insertions(+) create mode 100644 docs/architecture/data-flow-explainer.html diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html new file mode 100644 index 0000000..80be39b --- /dev/null +++ b/docs/architecture/data-flow-explainer.html @@ -0,0 +1,2117 @@ + + + + + +data-flow / template-vertical / explainer + + + + + + + +
+
+ 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.

+
+ + +
+ +
+ + +
+
+
§ 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
+
+
+
+ +
+ + + + + + +