Source: ddx-documentation/adr/0008-singleton-scope.md
- **Status:** Accepted (2026-10-23) - **Binds:** G6.3, WS6 NOT (no request data in module singletons) - **Research:** [RQ6](/en/docs/research/rq6-di-scope) · **Related:** [0007](/en/docs/adr/0007-di-container) ## Context On the Next.js server, a module-level value lives for the whole Node process and is shared by every request and every user (RQ6). A singleton that holds the current user, org, session, locale or settings shows one tenant's data to another tenant. This is a security defect, not a style issue. ## Decision ### Allowed singletons (module level, any side) | Allowed | Why safe | |---|---| | Frozen pure config (`Object.freeze`d brand map, nav registry, route table, feature list) | read-only, the same for everyone | | Logger **factory** (`createLogger(scope)`) and the base pino instance | stateless; request ids are passed per call / child logger per request | | Pure functions, Zod schemas, query-key factories, `cva` variant definitions | no state | | Browser-only: TanStack `QueryClient` per tab, the SSE manager (`lib/sse/manager.ts`) per tab | one user per tab; both reset on sign-out / org switch | | NestJS `DEFAULT`-scope services that hold no request data | state lives in CLS / request context | ### Forbidden singletons | Forbidden | Use instead | |---|---| | **Any module-level value that holds user, org, session, role, locale, settings or tokens on the server** (`let currentOrg`, `const cache = new Map()` keyed by nothing, a memoised `getSession` outside `cache()`) | React `cache()` per request (ADR 0007), or pass as argument | | A server-side `QueryClient` at module level | `cache(() => new QueryClient())` per request | | A module-level DI container on the server | `getServerContainer = cache(...)` | | Module-level mutable state in a `'use server'` file | per-call data in the action body | | `globalThis.*` stores of request data | none — never | | NestJS `DEFAULT` provider with a mutable field set per request | CLS context or `Scope.REQUEST` | ## Rejected alternative - **"Singleton everywhere, clear it per request"**: rejected — concurrent requests in one process interleave; clearing does not isolate them. ## Consequences - Review rule: any `let` or mutable `Map`/`Set` at module scope in `ddx-web/src` (server modules) needs a comment naming the allow-list row, or it is a defect. - The two-org isolation proof (RQ6) is the regression test for this ADR. ## Check ```sh rg -n '^(let|var) ' ddx-web/src --glob '!**/*.test.*' # every hit must be allow-listed rg -n '^const \w+ = new (Map|Set|QueryClient)\(' ddx-web/src # review each hit ```
On the Next.js server, a module-level value lives for the whole Node process and is shared by every request and every user (RQ6). A singleton that holds the current user, org, session, locale or settings shows one tenant's data to another tenant. This is a security defect, not a style issue.
let or mutable Map/Set at module scope in ddx-web/src (server
modules) needs a comment naming the allow-list row, or it is a defect.adr/
deploy/
research/
rg -n '^(let|var) ' ddx-web/src --glob '!**/*.test.*' # every hit must be allow-listedrg -n '^const \w+ = new (Map|Set|QueryClient)\(' ddx-web/src # review each hit