Source: ddx-documentation/research/rq6-di-scope.md
**Question** (BRIEF.md RQ6, G6.3): how do Dependency Injection, Inversion of Control and the Singleton pattern behave across React Server Component requests, client bundles and the NestJS container? Evidence: per-request vs per-process scope. **Answer:** "singleton" means a different lifetime in each runtime. A module-level value on the Next.js server lives for the whole **process** and is shared by every user. Request data must use React `cache()` (one value per **request**). In the browser, a module value lives per **tab**, and React context lives per **tree**. NestJS `DEFAULT` scope is per process, `REQUEST` scope is per HTTP request. Decisions: ADR [0006](/en/docs/adr/0006-factory), [0007](/en/docs/adr/0007-di-container), [0008](/en/docs/adr/0008-singleton-scope). ## Scope table | Runtime | Mechanism | Lifetime | Shared between users? | Safe to hold user/org data? | |---|---|---|---|---| | Next.js server (RSC, Route Handler) | module-level `const` / `let` | Node **process** (until restart / redeploy) | **YES** | **NO** — leaks across requests | | Next.js server | React `cache(fn)` | one **server request** (one render pass) | no | yes | | Next.js server | `'use cache'` + `cacheTag` | cache entry, across requests, keyed by arguments | yes, by key | only if the key holds the org id (see RQ7) | | Next.js server | `headers()` / `cookies()` | one request | no | yes (read source, not a store) | | Browser bundle | module-level value | one **tab** (page load) | no (one user per tab) | yes for that user, but not across sign-out — reset on sign-out | | Browser bundle | React context provider | one **React tree** mount | no | yes — preferred | | Browser bundle | TanStack `QueryClient` | singleton per tab (`tanstack5` rule) | no | yes, keys carry `orgId` | | NestJS | provider, `Scope.DEFAULT` | **process** | yes | NO for request data; yes for stateless services | | NestJS | provider, `Scope.REQUEST` | one HTTP request (bubbles up the chain) | no | yes, but costs one instance per request per dependent | | NestJS | `AsyncLocalStorage` (CLS) context | one async request chain | no | yes; keeps services in DEFAULT scope | ## Findings 1. **RSC module singletons leak.** Next.js renders many requests in one Node process. A `let currentOrg` at module level is written by request A and read by request B. This is the forbidden case of ADR 0008. 2. **`cache()` is the server request scope.** `cache(() => createServerContainer())` returns the same container for every component of one request, and a new one for the next request. It needs no provider and works in any Server Component, layout or Route Handler. 3. **Client scope = React context.** The client container is built once per provider mount from serialisable inputs (org slug, locale, settings promise). Two trees (site, portal) get two containers. No `window` global. 4. **IoC without a framework.** Components ask for a service by typed key (`container.get('entities')`) or by hook (`useService('entities')`). They never import a concrete client or call `new`. This is the whole IoC gain; decorators and `reflect-metadata` add nothing in RSC. 5. **NestJS:** keep services in `DEFAULT` scope and stateless. Resolve tenant + user once per request in a guard into a CLS/request context (`@OrgId()` decorator reads it). Use `Scope.REQUEST` only where a provider must hold request state and CLS is not enough — `REQUEST` scope bubbles to every dependent and costs one instance per request. 6. **Per-process is fine for pure things.** Frozen config, the logger factory, the brand token map and pure functions may be module-level (ADR 0008 allow list). ## Prototype shape (to build in task 022+, not code here) - Server: `src/lib/di/server-container.ts` starts with `import 'server-only'`, exports `getServerContainer = cache(() => buildContainer({ side: 'server', ... }))`. - Client: `src/lib/di/client-container.tsx` (`'use client'`) — `ServiceProvider` builds the container in `useState(() => buildContainer({ side: 'client', ... }))`; `useService(key)`. - Proof test (when an AC names it): render two requests with two orgs; the container of request 2 must not see the org of request 1. ## Sources - React `cache` — <https://react.dev/reference/react/cache> (per server request memoisation). - NestJS injection scopes — <https://docs.nestjs.com/fundamentals/injection-scopes>. - `ddx-server-client-boundary` skill (`import 'server-only'`, module-scope mutable state rule). - `ddx-nextjs-frontend-layout` D3/D6 (cascade, entity-client singletons in the browser). > Note 2026-10-23: `ServiceProvider` above is now `ClientContainerProvider` (`ddx-web/src/lib/di/client-container.tsx:22`).
Question (BRIEF.md RQ6, G6.3): how do Dependency Injection, Inversion of Control and the Singleton pattern behave across React Server Component requests, client bundles and the NestJS container? Evidence: per-request vs per-process scope.
Answer: "singleton" means a different lifetime in each runtime. A module-level value on the
Next.js server lives for the whole process and is shared by every user. Request data must
use React cache() (one value per request). In the browser, a module value lives per
tab, and React context lives per tree. NestJS DEFAULT scope is per process,
REQUEST scope is per HTTP request. Decisions: ADR ,
, .
let currentOrg at module level is written by request A and read by request B. This is
the forbidden case of ADR 0008.cache() is the server request scope. cache(() => createServerContainer()) returns
the same container for every component of one request, and a new one for the next request.
It needs no provider and works in any Server Component, layout or Route Handler.window global.container.get('entities')) or by hook (useService('entities')). They never import a
concrete client or call new. This is the whole IoC gain; decorators and
reflect-metadata add nothing in RSC.DEFAULT scope and stateless. Resolve tenant + user once per
request in a guard into a CLS/request context (@OrgId() decorator reads it). Use
Scope.REQUEST only where a provider must hold request state and CLS is not enough —
REQUEST scope bubbles to every dependent and costs one instance per request.src/lib/di/server-container.ts starts with import 'server-only', exports
getServerContainer = cache(() => buildContainer({ side: 'server', ... })).src/lib/di/client-container.tsx ('use client') — ServiceProvider builds the
container in useState(() => buildContainer({ side: 'client', ... })); useService(key).cache — (per server request memoisation).ddx-server-client-boundary skill (import 'server-only', module-scope mutable state rule).ddx-nextjs-frontend-layout D3/D6 (cascade, entity-client singletons in the browser).Note 2026-10-23:
ServiceProviderabove is nowClientContainerProvider(ddx-web/src/lib/di/client-container.tsx:22).
adr/
deploy/
research/