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.
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.
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.
ContainerModule only runs when the module is loaded onto a container.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.
import { blogContainer } from "./container". Module-level side effects. Once per process (Node caches imports).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.
apps/web-next/src/server/bind-production.ts) when USE_DEV_SEED ≠ "true" AND Payload config is resolvable.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.
USE_DEV_SEED === "true". Storybook stories that need data may call this directly.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.
pnpm test --filter @repo/blog. Never in production.How they connect.
Module imports point in one direction; the loading sequence flows in another. Here is who imports who, and who runs first.
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.
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.
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";
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);
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(/* ... */); });
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 }
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> { // 1. Explicit override wins, regardless of NODE_ENV. if (process.env.USE_DEV_SEED === "true") { await bindAllDevSeed(); return; } // 2. Production env → real Payload. if (process.env.NODE_ENV === "production") { await bindAllProduction(); return; } // 3. Default: dev seed, so `pnpm dev` boots without Payload. await bindAllDevSeed(); }
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
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 })
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(/* ... */);
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?"
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.
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.
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.
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.
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.
blogContainer · state
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?
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();
Instrumentation symbols.
Plan 10 added two new symbols to the per-feature container — TRACER and LOGGER — bound by a separate Rule 0 in bindAll() that's orthogonal to the repo binding mode. The DSN env var decides Sentry vs Noop; USE_DEV_SEED / NODE_ENV decide real vs mock repos.
INSTRUMENTATION_SYMBOLS.TRACER
Bound by either bindNoopInstrumentation or bindSentryInstrumentation to NoopTracer or SentryTracer. Decided by Rule 0: DSN env present → Sentry; otherwise Noop.
INSTRUMENTATION_SYMBOLS.LOGGER
Same rule, same lifecycle. NoopLogger in the absence of a DSN; SentryLogger when DSN is set. The Sentry adapter applies the __sentryReported double-report guard internally — call sites don't manage the flag.
Wiring path
bindAll()
└─ resolveInstrumentation() ← Rule 0 (DSN check)
└─ Noop or Sentry binders ← bind to sharedContainer
└─ bindProductionX(config, tracer, logger)
└─ feature container also binds TRACER + LOGGER
└─ withSpan(withCapture(...)) at every use case + controller
Why per-feature containers also get the binding: repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.
Two wrappers, applied as a sandwich
withSpan and withCapture are higher-order functions that take a (args) => Promise<R> and return the same shape. The binders compose them: withSpan(withCapture(factory(deps))). Span is outermost so an errored span's timing reflects the capture-and-rethrow.
| Wrapper | What it does | Where it fires |
|---|---|---|
withSpan(tracer, opts, fn) |
Calls tracer.startSpan(opts, () => fn(...)). Pure delegation — no error handling of its own; status-on-error logic lives in the tracer impl. |
Around every use case + controller, at DI bind time |
withCapture(logger, tags, fn) |
On throw: checks __sentryReported; if not set, calls logger.captureException(err, { tags }), marks the flag, re-throws. If already set, just re-throws. |
Around every use case + controller, inside the span wrapper |
Repositories are different — they call this.tracer.startSpan + this.logger.captureException inline per method, because they own the per-call attributes (count, IDs, slugs) that the wrapper has no way to know.