§ 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(ctx: BindProductionContext)`. Destructures `{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }` from `ctx`. Function body: `blogContainer.unbind(symbol)` if already bound, then `.bind(symbol).toConstantValue(new ArticlesRepository(config, tracer, logger))`. Use cases and controllers are wrapped via `withSpan(withCapture(factory(deps)))` at bind time so they inherit instrumentation without changing their factory bodies. The optional `bus` and `queue` fields come from the app's `resolveEventsAndJobs*` step (ADR-015) and feed the `// <gen:event-handlers>` / `// <gen:jobs>` injection sites. `BindProductionContext` is imported from `@repo/core-shared/di` — features never import the optional packages directly for the binder signature.

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(ctx)`. 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> {
      // 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();
    }

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
Server (any env) with USE_DEV_SEED=true · explicit override wins
bindAll() → bindAllDevSeed()
dev seed · populated mock
NODE_ENV=production, USE_DEV_SEED unset
bindAll() → bindAllProduction(config)
production · real Payload
pnpm dev (NODE_ENV=development or unset), no env flag
bindAll() → bindAllDevSeed() (default)
dev seed · populated mock
Storybook story that wants populated data
await bindDevSeedBlog(ctx)
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();

§ 08

## Instrumentation symbols.

ADR-014 + ADR-017 added instrumentation symbols to the per-feature container — `ITracer`, `ILogger`, and `IMetrics` — bound by a separate Rule 0 in `bindAll()` that's orthogonal to the repo binding mode. The DSN env var decides OTel+Sentry vs Noop; `USE_DEV_SEED` / `NODE_ENV` decide real vs mock repos. Substrate: OpenTelemetry SDK. Sentry is the exporter via `@sentry/opentelemetry`. ADR-018 adds `IAuditLog` as a parallel channel with different durability and redaction contracts — see audit-and-compliance-explainer.html.

### `INSTRUMENTATION_SYMBOLS.ITracer`

Bound by either `bindNoopInstrumentation` or `bindOtelInstrumentation` to `NoopTracer` or `OtelTracer`. Decided by Rule 0: DSN env present → OTel SDK + Sentry exporter; otherwise Noop. `OtelTracer` emits via `@opentelemetry/api`; spans flow to Sentry via `SentrySpanProcessor`.

### `INSTRUMENTATION_SYMBOLS.ILogger`

Same rule, same lifecycle. `NoopLogger` in the absence of a DSN; `OtelLogger` when DSN is set. `OtelLogger` emits log records via `@opentelemetry/api-logs`; errors flow to Sentry via `SentryLogRecordProcessor`. The `__sentryReported` double-report guard is applied before emitting.

### `INSTRUMENTATION_SYMBOLS.IMetrics`

Bound to `NoopMetrics` or `OtelMetrics`. `OtelMetrics` uses the OTel metrics API (`counter`, `histogram`, `gauge`). Per-feature adoption is opportunistic — no feature call sites required.

### `IAuditLog` (ADR-018, optional)

Not a symbol — passed through `ctx.auditLog`, not bound on the per-feature container. Present only when `@repo/core-audit` is scaffolded via `pnpm turbo gen core-package audit`. Pre-wrapped in `TraceIdEnrichingAuditLog` so callers don't supply `correlationId` — it's read from the active OTel span at sink time. Parallel channel: OTel is sampled/pruned best-effort observability; audit is append-only and retained for compliance. Same `traceId` lives in both, which is the debugging bridge.

### Wiring path

    bindAll()
      └─ resolveInstrumentation()          ← Rule 0 (DSN check)
           └─ bindOtelInstrumentation()    ← OTel SDK init + Sentry exporter + PII scrub processors
             OR bindNoopInstrumentation()  ← all Noop
      └─ resolveEventsAndJobs*()          ← ADR-015 (env-driven bus + queue)
           └─ Payload-backed in prod, in-memory in dev-seed
      └─ resolveRealtime()                 ← ADR-016 (Socket.IO or in-memory)
      └─ resolveAudit()                    ← ADR-018 (TraceIdEnriching( MultiSink([Stdout, Payload]) ))
           └─ absent → ctx.auditLog is undefined; record() calls become no-ops via ?.
      └─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }
           └─ required: tracer, logger, config  |  optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog
      └─ bindProductionX(ctx)             ← single ctx object passed to each feature binder
           └─ feature container also binds TRACER + LOGGER
           └─ withSpan(withCapture(...)) at every use case + controller
           └─ // <gen:event-handlers> / // <gen:jobs> / // <gen:realtime-handlers> / // <gen:audit-hooks> injection sites

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.