From 68e934c0a5be71cb5594038a9d49a32e640cec4f Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 6 May 2026 19:10:29 +0200 Subject: [PATCH] feat(app): wire bindAll() + bindAllDevSeed() dispatcher; add DI explainer page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web-next/src/server/bind-production.ts now exports three functions: - bindAllProduction() — production-only binders - bindAllDevSeed() — dev-seed-only binders (NEW, calls all 5 features) - bindAll() — dispatcher that branches on USE_DEV_SEED env var All page/route callers (page.tsx, about/page.tsx, blog/[slug]/page.tsx, api/trpc/[trpc]/route.ts) updated from bindAllProduction → bindAll so the env flag actually has effect. docs/architecture/di-explainer.html (NEW): standalone interactive page explaining the di/ folder file-by-file, the loading sequence (8 stages), the three binding kinds (.to / .toDynamicValue / .toConstantValue), an interactive three-mode picker showing how the same blogContainer state differs across default/dev-seed/production, a conditions table, and a final card on how tests bypass DI entirely. Sister page to data-flow-explainer.html. Refactor log entry + canonical doc updates follow in subsequent commits. --- apps/web-next/src/app/about/page.tsx | 4 +- .../web-next/src/app/api/trpc/[trpc]/route.ts | 4 +- apps/web-next/src/app/blog/[slug]/page.tsx | 4 +- apps/web-next/src/app/page.tsx | 4 +- apps/web-next/src/server/bind-production.ts | 41 + docs/architecture/di-explainer.html | 1270 +++++++++++++++++ 6 files changed, 1319 insertions(+), 8 deletions(-) create mode 100644 docs/architecture/di-explainer.html diff --git a/apps/web-next/src/app/about/page.tsx b/apps/web-next/src/app/about/page.tsx index 2f85681..d9438b6 100644 --- a/apps/web-next/src/app/about/page.tsx +++ b/apps/web-next/src/app/about/page.tsx @@ -1,8 +1,8 @@ import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../../server/bind-production"; +import { bindAll } from "../../server/bind-production"; export default async function AboutPage() { - await bindAllProduction(); + await bindAll(); const caller = appRouter.createCaller({}); const page = await caller.marketingPages.pageBySlug({ slug: "about" }); diff --git a/apps/web-next/src/app/api/trpc/[trpc]/route.ts b/apps/web-next/src/app/api/trpc/[trpc]/route.ts index fbf179d..f1fddf3 100644 --- a/apps/web-next/src/app/api/trpc/[trpc]/route.ts +++ b/apps/web-next/src/app/api/trpc/[trpc]/route.ts @@ -1,9 +1,9 @@ import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../../../../server/bind-production"; +import { bindAll } from "../../../../server/bind-production"; const handler = async (req: Request) => { - await bindAllProduction(); + await bindAll(); return fetchRequestHandler({ endpoint: "/api/trpc", req, diff --git a/apps/web-next/src/app/blog/[slug]/page.tsx b/apps/web-next/src/app/blog/[slug]/page.tsx index 9705f2e..c044e33 100644 --- a/apps/web-next/src/app/blog/[slug]/page.tsx +++ b/apps/web-next/src/app/blog/[slug]/page.tsx @@ -1,13 +1,13 @@ import { notFound } from "next/navigation"; import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../../../server/bind-production"; +import { bindAll } from "../../../server/bind-production"; type PageProps = { params: Promise<{ slug: string }>; }; export default async function BlogPostPage({ params }: PageProps) { - await bindAllProduction(); + await bindAll(); const { slug } = await params; const caller = appRouter.createCaller({}); const article = await caller.blog.articleBySlug({ slug }); diff --git a/apps/web-next/src/app/page.tsx b/apps/web-next/src/app/page.tsx index 8800e91..df98af7 100644 --- a/apps/web-next/src/app/page.tsx +++ b/apps/web-next/src/app/page.tsx @@ -1,9 +1,9 @@ import Link from "next/link"; import { appRouter } from "@repo/core-api"; -import { bindAllProduction } from "../server/bind-production"; +import { bindAll } from "../server/bind-production"; export default async function Home() { - await bindAllProduction(); + await bindAll(); const caller = appRouter.createCaller({}); const [siteSettings, header, articles] = await Promise.all([ diff --git a/apps/web-next/src/server/bind-production.ts b/apps/web-next/src/server/bind-production.ts index aeff114..a946f26 100644 --- a/apps/web-next/src/server/bind-production.ts +++ b/apps/web-next/src/server/bind-production.ts @@ -5,9 +5,18 @@ import { bindProductionAuth } from "@repo/auth/di/bind-production"; import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production"; import { bindProductionNavigation } from "@repo/navigation/di/bind-production"; import { bindProductionMedia } from "@repo/media/di/bind-production"; +import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed"; +import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed"; +import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed"; +import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed"; +import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed"; let bound = false; +/** + * Production path: swap each feature's mock repository binding for the real + * Payload-backed one. Constructs `new XRepository(config)` per feature. + */ export async function bindAllProduction(): Promise { if (bound) return; bound = true; @@ -18,3 +27,35 @@ export async function bindAllProduction(): Promise { bindProductionNavigation(resolvedConfig); bindProductionMedia(resolvedConfig); } + +/** + * Dev-seed path: keep each feature's MockXRepository in place but populate it + * with realistic seed data so the running app shows non-empty UI without + * Payload booted. Useful for storybook, design review, and offline dev. + * + * Mutually exclusive with `bindAllProduction()` — both rebind the same symbols. + */ +export async function bindAllDevSeed(): Promise { + if (bound) return; + bound = true; + await bindDevSeedAuth(); + await bindDevSeedBlog(); + await bindDevSeedMarketingPages(); + await bindDevSeedNavigation(); + await bindDevSeedMedia(); +} + +/** + * Boot dispatcher: pick the binder based on `process.env.USE_DEV_SEED`. + * Idempotent — guarded by the same `bound` flag as `bindAllProduction`. + * + * Call this from server entry points (route handlers, page server components) + * before resolving any feature controller. + */ +export async function bindAll(): Promise { + if (process.env.USE_DEV_SEED === "true") { + await bindAllDevSeed(); + return; + } + await bindAllProduction(); +} diff --git a/docs/architecture/di-explainer.html b/docs/architecture/di-explainer.html new file mode 100644 index 0000000..ceef150 --- /dev/null +++ b/docs/architecture/di-explainer.html @@ -0,0 +1,1270 @@ + + + + + +di-folder / template-vertical / explainer + + + + + + + +
+
+ template-vertical / di-folder / explainer + 2026-05-06 +
+
+

The di/ folder,
file by file.

+

Every feature has six files in src/di/ that wire its repositories, services, use cases and controllers into the InversifyJS container. Here is what each file does, when its code runs, what conditions select which binding mode, and how tests bypass it entirely.

+
+ +
+ +
+ + +
+
+
§ 01
+
+

The cast.

+

Six files (per feature) live in src/di/. Three are loaded automatically when the container module is imported; three are dispatched explicitly from app boot or from tests. Reading them in this order is the right mental model.

+
+
+ +
+
+
file 01 · keys
+

symbols.ts

+

The address book. Plain object whose values are Symbol.for(...) keys, one per binding the container holds.

+

Symbols are the type-erased hooks the container indexes by. Every bind call on the container references one of these symbols; every container.get call passes one back in. Without symbols you would need either string keys (collision-prone) or class references (forces eager imports). Symbol.for("blog:IGetArticlesUseCase") is namespaced to the feature so two features can both have an IGetArticlesUseCase binding without colliding.

+
When it runs: module load. Pure constants — zero behavior.
+
+ +
+
file 02 · the wiring
+

module.ts

+

The default binding map. Imports every concrete class and factory function in the feature, registers each one under its symbol.

+

Exports a ContainerModule built with new ContainerModule((bind) => { ... }). Inside the callback, three kinds of bindings are registered: .to(Class) for repositories that have @injectable classes (the mock impl), and .toDynamicValue((ctx) => factory(ctx.container.get(...))) for use-case and controller factory functions. The module is just a description of bindings — nothing executes until something asks the container to resolve a symbol.

+
When it runs: module load (registration only). The callback inside ContainerModule only runs when the module is loaded onto a container.
+
+ +
+
file 03 · the singleton
+

container.ts

+

Constructs the singleton container, loads the module, and exports the container instance.

+

Three lines: import "reflect-metadata" (required by inversify's decorator metadata), new Container({ defaultScope: "Singleton" }), container.load(BlogModule). Now the module's binding callback runs — every bind() call inside it executes and registers its symbol. The container does not yet construct any of the bound implementations. Resolution is lazy.

+
When it runs: first import { blogContainer } from "./container". Module-level side effects. Once per process (Node caches imports).
+
+ +
+
file 04 · prod swap
+

bind-production.ts

+

Replaces the mock repository binding with a real Payload-backed one at app boot.

+

Exports bindProductionBlog(config: SanitizedConfig). Function body: blogContainer.unbind(symbol) if already bound, then .bind(symbol).toConstantValue(new ArticlesRepository(config)). Use cases and controllers stay bound to their factory bindings — they will fetch the new repo through the container automatically because their factories call ctx.container.get(...) at every resolution.

+
When it runs: called from app boot (apps/web-next/src/server/bind-production.ts) when USE_DEV_SEED ≠ "true" AND Payload config is resolvable.
+
+ +
+
file 05 · seeded mock
+

bind-dev-seed.ts

+

Replaces the empty mock with a populated mock so the running app shows realistic data without Payload.

+

Exports bindDevSeedBlog(). Same shape as bind-production — unbind, rebind. The difference: it constructs a fresh MockArticlesRepository, seeds it via buildDevArticles() from src/__seeds__/dev.ts, and binds the populated instance via .toConstantValue(repo). Mutually exclusive with bindProductionBlog — both operate on the same symbol.

+
When it runs: called from app boot when USE_DEV_SEED === "true". Storybook stories that need data may call this directly.
+
+ +
+
file 06 · proof
+

container.test.ts

+

Unit test that proves every symbol resolves and that the default binding is the mock.

+

Tests run with blogContainer.unbindAll() in beforeEach and blogContainer.load(BlogModule) to start from a clean slate. Then assertions like expect(repo).toBeInstanceOf(MockArticlesRepository) and expect(typeof ctrl).toBe("function"). Cheap insurance against typos in module.ts.

+
When it runs: pnpm test --filter @repo/blog. Never in production.
+
+
+
+ + +
+
+
§ 02
+
+

How they connect.

+

Module imports point in one direction; the loading sequence flows in another. Here is who imports who, and who runs first.

+
+
+ +
+
+
+
file 01
+ symbols.ts +
Pure constants — exports BLOG_SYMBOLS. No imports from other di/ files.
+
+
imported by ↓
+
+
file 02
+ module.ts +
Imports symbols.ts + every impl class + every factory function. Builds ContainerModule.
+
+
imported by ↓
+
+
file 03
+ container.ts +
Imports symbols.ts + module.ts. Constructs Container, calls .load(BlogModule). Exports the singleton.
+
+
imported by ↓
+
+
+
file 04 · prod path
+ bind-production.ts +
Imports container.ts + symbols.ts + the real ArticlesRepository class. App calls it at boot.
+
+
+
file 05 · dev path
+ bind-dev-seed.ts +
Imports container.ts + symbols.ts + MockArticlesRepository + buildDevArticles. App calls it at boot when USE_DEV_SEED.
+
+
+
tested by ↓ (separate import chain)
+
+
file 06 · only at test time
+ container.test.ts +
Imports container.ts + symbols.ts + module.ts + MockArticlesRepository. Asserts every symbol resolves correctly.
+
+
+
+ +

Key fact: nothing inside di/ imports the app. The relationship is one-way: the app imports ./di/bind-production (or ./di/bind-dev-seed), the feature exports those binders. The feature has no idea what app is running it. That is what keeps core-shared and the feature packages boundary-clean while still letting Payload config flow in.

+
+ + +
+
+
§ 03
+
+

The loading sequence.

+

Eight things happen between node starting and the first request reaching a controller. Some run once at module-load time, some run per-process at app boot, some run per-request.

+
+
+ +
+
+
01
+
+
Module load · once per process
+

Some code path imports blogContainer.

+

Could be the tRPC router, could be a feature test, could be the app's bind-production module. Whatever it is, the import triggers Node to evaluate packages/blog/src/di/container.ts.

+
+
import { blogContainer } from "../../di/container";
+
+ +
+
02
+
+
Module load · same evaluation
+

container.ts runs top-to-bottom.

+

Three statements: import "reflect-metadata", new Container({ defaultScope: "Singleton" }), blogContainer.load(BlogModule). The container instance is now memoized on the module; future imports reuse it.

+
+
import "reflect-metadata";
+import { Container } from "inversify";
+import { BlogModule } from "./module";
+
+export const blogContainer = new Container({ defaultScope: "Singleton" });
+blogContainer.load(BlogModule);
+
+ +
+
03
+
+
Module load · cascading import
+

module.ts evaluates.

+

Imports BLOG_SYMBOLS, the mock repo class, every use-case + controller factory. Defines BlogModule by passing a callback into new ContainerModule((bind) => { ... }). The callback does not run yet — it is stored on the module.

+
+
export const BlogModule = new ContainerModule((bind) => {
+  // callback body — stored, not executed yet
+  bind<IArticlesRepository>(BLOG_SYMBOLS.IArticlesRepository).to(MockArticlesRepository);
+  bind<IGetArticlesUseCase>(BLOG_SYMBOLS.IGetArticlesUseCase).toDynamicValue(/* ... */);
+});
+
+ +
+
04
+
+
Module load · `.load()` triggers
+

The module callback runs; bindings register.

+

This is where bind<IArticlesRepository>(symbol).to(MockArticlesRepository) actually executes. The container records the binding kind for each symbol. Still nothing constructed — the mock repo is a class reference, the use-case factory is a closure, both inert.

+
+
// Internally, the container now holds a Map<Symbol, Binding>:
+Map {
+  Symbol.for("blog:IArticlesRepository") => { kind: "class", target: MockArticlesRepository },
+  Symbol.for("blog:IGetArticlesUseCase") => { kind: "dynamicValue", factory: fn },
+  // ... 5 more
+}
+
+ +
+
05
+
+
App boot · once per server start
+

app calls bindAll().

+

The web app's server entry point runs bindAll(), which checks process.env.USE_DEV_SEED and dispatches to either bindAllProduction() or bindAllDevSeed(). Each calls every feature's binder.

+
+
export async function bindAll(): Promise<void> {
+  if (process.env.USE_DEV_SEED === "true") {
+    await bindAllDevSeed();
+    return;
+  }
+  await bindAllProduction();
+}
+
+ +
+
06
+
+
App boot · per feature
+

Each binder swaps the repo binding.

+

Whichever binder runs, it does the same thing for the repository symbol: unbind the old binding, bind a new .toConstantValue(impl). Use case and controller bindings stay untouched — they keep their .toDynamicValue closures and will pick up the new repo on next resolve.

+
+
if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
+  blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
+}
+blogContainer
+  .bind(BLOG_SYMBOLS.IArticlesRepository)
+  .toConstantValue(new ArticlesRepository(config));  // or new MockArticlesRepository() with seed data
+
+ +
+
07
+
+
Per request · on demand
+

tRPC handler calls container.get(SYMBOL).

+

A request hits a procedure. The handler asks the container for the controller. The container looks up the symbol, finds kind: "dynamicValue", runs the factory closure. The closure calls ctx.container.get for the use case symbol, which itself runs another factory closure, which fetches the (now-rebound) repo. A controller closure is returned, ready to call.

+
+
listArticles: blogProcedure
+  .input(getArticlesInputSchema)
+  .query(({ input }) => {
+    const ctrl = blogContainer.get<IGetArticlesController>(
+      BLOG_SYMBOLS.IGetArticlesController,    // ← lazy: factory closures fire here
+    );
+    return ctrl(input);                        // ← then we invoke the closure
+  })
+
+ +
+
08
+
+
Singleton scope · cache
+

Subsequent requests get the same instance.

+

Because the container was constructed with defaultScope: "Singleton", the controller closure (and every dependency it captured) is cached after the first get(). Subsequent calls within the same process reuse it. To get a fresh closure you must unbind and rebind, which is exactly what bindProductionBlog does for the repo symbol.

+
+
// First request:
+blogContainer.get(SYMBOL);  // runs factory, caches result
+
+// Second request:
+blogContainer.get(SYMBOL);  // returns cached value, no factory call
+
+// To get a fresh wiring (e.g. after rebinding the repo):
+blogContainer.unbind(SYMBOL);
+blogContainer.bind(SYMBOL).toDynamicValue(/* ... */);
+
+
+
+ + +
+
+
§ 04
+
+

Three binding kinds.

+

The container supports many binding modes; this codebase uses three. Each fits a different shape of dependency. Knowing which to reach for is most of "how do I add a thing to the container?"

+
+
+ +
+
+
.to(Class)
+
use for · @injectable classes
+

The container will new Class() the first time the symbol is resolved (or use cached instance after, since scope is Singleton). Class must be decorated with @injectable. Constructor params are resolved via @inject(SYMBOL) decorators on the parameters.

+
bind<IArticlesRepository>(SYM).to(MockArticlesRepository);
+
+ +
+
.toDynamicValue((ctx) => ...)
+
use for · factory functions
+

The container runs the callback when the symbol is resolved. The callback receives a context object with container.get available, so you can fetch dependencies and pass them into the factory. Returns a closure (the wired-up function). Used for every use case and every controller in this codebase, because they are factory-style: (deps) => async (input) => result.

+
bind<IGetArticlesUseCase>(SYM).toDynamicValue((ctx) => + getArticlesUseCase( + ctx.container.get(BLOG_SYMBOLS.IArticlesRepository), + ), +);
+
+ +
+
.toConstantValue(instance)
+
use for · pre-constructed instances
+

You give the container an already-built object; it returns the same reference every time. Used by bindProduction*(config) because the real Payload-backed repository takes config as a constructor argument and the container has no way to provide that on its own. Also used by bindDevSeed* because the populated mock requires async createArticle calls during construction.

+
blogContainer + .bind(SYM) + .toConstantValue(new ArticlesRepository(config));
+
+
+ +

The progression is significant. .to is what inversify was originally built for — class-based DI, decorator-driven. .toDynamicValue opens the door to functional DI, which is what every use case and controller in this repo actually wants. .toConstantValue is the escape hatch for "I built this thing myself, just remember it for me." All three coexist in the same container without conflict.

+
+ + +
+
+
§ 05
+
+

Three modes, one container.

+

Same set of symbols, three possible states the container can be in. Pick a mode below to see which bindings change and which stay put.

+
+
+ +
+ mode +
+ + + +
+
+ +
+
+

blogContainer · state

+
USE_DEV_SEED ≠ "true" · no bindProduction* called yet
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+
§ 06
+
+

When does which mode run?

+

Six concrete scenarios that select a mode. The trigger is always the same: which binder did the entry point call?

+
+
+ +
+
+ scenario + trigger + resulting mode +
+
+
Just opened in your editor; nothing imported the container yet
+
no import
+
— (no container)
+
+
+
Test file imports the container directly
+
import "@/di/container"
+
default · empty mock
+
+
+
Dev server with USE_DEV_SEED=true
+
bindAll() → bindAllDevSeed()
+
dev seed · populated mock
+
+
+
Dev server without env flag (Payload booted)
+
bindAll() → bindAllProduction(config)
+
production · real Payload
+
+
+
Production server (NODE_ENV=production)
+
bindAll() → bindAllProduction(config)
+
production · real Payload
+
+
+
Storybook story that wants populated data
+
await bindDevSeedBlog()
+
dev seed · populated mock
+
+
+
Use-case unit test (skips DI entirely)
+
new MockArticlesRepository()
+
— (no container resolved)
+
+
+
+ + +
+
+
§ 07
+
+

How tests bypass all of this.

+

The DI lifecycle above describes the runtime. Most tests do not run any of it — they construct mocks directly and call factory functions. The two patterns coexist; the container is only one of them.

+
+
+ +
+
+

Tests that do use the container

+

Two kinds: container.test.ts (proves bindings resolve), and integrations/api/router.test.ts (proves the tRPC router resolves the right controller through DI). Both rebind the repo symbol via blogContainer.unbind + .toConstantValue(mock) in beforeEach, then run the tRPC procedure or look up the symbol directly.

+

Why these and not others? Because the tRPC router calls container.get internally — there is no way to test the procedure without going through DI.

+
beforeEach(() => {
+  if (blogContainer.isBound(BLOG_SYMBOLS.IArticlesRepository)) {
+    blogContainer.unbind(BLOG_SYMBOLS.IArticlesRepository);
+  }
+  blogContainer
+    .bind(BLOG_SYMBOLS.IArticlesRepository)
+    .toConstantValue(new MockArticlesRepository());
+});
+
+ +
+

Tests that do not

+

Use-case and controller tests skip the container entirely. They construct the mock with new MockArticlesRepository() and pass it directly into the factory function. No container.load, no symbols, no rebinding. Just three lines of setup, then assertions.

+

This is the default — it is what every non-router test in the repo does. The same MockArticlesRepository class that the container binds by default is also the class these tests instantiate. One artifact, two paths to it.

+
it("filters by status", async () => {
+  const repo = new MockArticlesRepository();
+  await repo.createArticle(articleFactory.build({ status: "published" }));
+
+  const useCase = getArticlesUseCase(repo);
+  const result = await useCase({ status: "published" });
+
+  expect(result).toHaveLength(1);
+});
+
+
+ +
+

The punchline: DI is for runtime, not tests.

+

The container exists because at runtime there is no other way for the tRPC router to find a controller without compile-time-knowing which one. Tests do know — they import the factory directly. So tests do not need DI, and using DI in tests would just couple them to the container's state.

+

That is the design: the mock implementation is reachable from both worlds (DI binds it as default; tests construct it directly), the contract suite verifies the mock and the real impl behave the same, and the container is the runtime-only mechanism that lets the same code resolve to different implementations depending on which binder ran at boot.

+
// Three places construct MockArticlesRepository:
+
+// 1. The container, by default (production binding swaps it).
+bind<IArticlesRepository>(SYM).to(MockArticlesRepository);
+
+// 2. bind-dev-seed, populated for dev mode.
+const repo = new MockArticlesRepository();
+for (const a of buildDevArticles()) await repo.createArticle(a);
+blogContainer.bind(SYM).toConstantValue(repo);
+
+// 3. Tests, directly. No container involved.
+const repo = new MockArticlesRepository();
+
+
+ +
+ + + + + + +