Source: ddx-documentation/adr/0007-di-container.md
- **Status:** Accepted (2026-10-23) - **Binds:** G6.3, G6.4, invariant 10 (each task registers only its own service file) - **Research:** [RQ6](/en/docs/research/rq6-di-scope) · **Related:** [0006](/en/docs/adr/0006-factory), [0008](/en/docs/adr/0008-singleton-scope) ## Context Separation of concerns needs: components render, services decide, clients fetch (G6.4). Components must get services without importing concrete classes. In RSC, a global container would be per process and leak user and org state (RQ6 finding 1). ## Decision 1. **One typed container shape.** `ServiceMap` is a TS interface: `{ entities: EntityService; settings: SettingsService; media: MediaService; ... }`. `container.get<K extends keyof ServiceMap>(key: K): ServiceMap[K]`. No string-any, no decorators, no `reflect-metadata`. 2. **Registration is per module.** Each service file exports a `register(c)` function. The `buildContainer({ side, org, locale })` function calls them. A task adds its own file and one line in the builder (owner of the builder: task 022). 3. **Server container = per request.** `export const getServerContainer = cache(() => buildContainer({ side: 'server', ... }))` in a module that starts with `import 'server-only'`. The org and session come from the DAL inside the request. Same container for every Server Component of one request; a new one for the next request. 4. **Client container = per React tree.** `ServiceProvider` (`'use client'`) builds the container once per mount (`useState` initialiser) from serialisable props (org slug, locale). Hooks: `useService(key)`. The site tree and the portal tree mount their own. 5. **No global mutable singleton in RSC.** No `export const container = ...` at module level on the server, no `globalThis.__container`. (Allowed module-level values: ADR 0008.) 6. Services take their dependencies as constructor / factory arguments (IoC). They never call `getServerContainer()` themselves. ## Rejected alternative - **inversify** (RQ5): rejected — decorators + `reflect-metadata`, and its singleton scope is per process, the exact leak this ADR forbids. - **Plain React context only, also on the server**: rejected — context does not exist in Server Components. ## Consequences - Tests build a container with fakes: `buildContainer({ side: 'server', overrides: {...} })`. - Per-request build cost is small (plain objects); measure only if a page shows it. ## Check ```sh ! rg -n 'reflect-metadata|inversify' ddx-web/src ! rg -n '^export (const|let) container' ddx-web/src rg -n 'cache\(' ddx-web/src/lib/di # server container wrapped in React cache() ``` ## Notes - 2026-10-23: the client provider is named `ClientContainerProvider` (`ddx-web/src/lib/di/client-container.tsx:22`), not `ServiceProvider`; decision unchanged.
Separation of concerns needs: components render, services decide, clients fetch (G6.4). Components must get services without importing concrete classes. In RSC, a global container would be per process and leak user and org state (RQ6 finding 1).
ServiceMap is a TS interface:
{ entities: EntityService; settings: SettingsService; media: MediaService; ... }.
container.get<K extends keyof ServiceMap>(key: K): ServiceMap[K]. No string-any, no
decorators, no reflect-metadata.register(c) function. The
buildContainer({ side, org, locale }) function calls them. A task adds its own file and
one line in the builder (owner of the builder: task 022).export const getServerContainer = cache(() => buildContainer({ side: 'server', ... }))
in a module that starts with import 'server-only'. The org and session come from the
DAL inside the request. Same container for every Server Component of one request; a new
one for the next request.ServiceProvider ('use client') builds the
container once per mount (useState initialiser) from serialisable props (org slug,
locale). Hooks: useService(key). The site tree and the portal tree mount their own.export const container = ... at module level on
the server, no globalThis.__container. (Allowed module-level values: ADR 0008.)getServerContainer() themselves.reflect-metadata, and its singleton scope is
per process, the exact leak this ADR forbids.buildContainer({ side: 'server', overrides: {...} }).ClientContainerProvider (ddx-web/src/lib/di/client-container.tsx:22), not ServiceProvider; decision unchanged.adr/
deploy/
research/
! rg -n 'reflect-metadata|inversify' ddx-web/src! rg -n '^export (const|let) container' ddx-web/srcrg -n 'cache\(' ddx-web/src/lib/di # server container wrapped in React cache()