Implicit request-scope propagation into default-scoped NestJS controllers
0 reputation · 27 Feb 2021, 20:53 UTC
0 reputation · 27 Feb 2021, 20:53 UTC
NestJS's dependency injection system propagates request scope through constructor injection chains, but the extent to which this propagation respects module boundaries remains ambiguous. When a default-scoped controller or provider injects a @Injectable({ scope: Scope.REQUEST }) service, the injected instance appears to adopt request scope even without an explicit annotation on the consuming class. This behavior suggests the DI container's resolution graph at bootstrap may override module-level scope declarations, potentially affecting unrelated consumers in importing modules.
The practical consequence is that a controller designed as a singleton can silently become request-scoped, triggering per-request instantiation of its entire dependency chain and increasing garbage collection pressure under high request throughput. Key uncertainty involves whether scope bubbling is confined to the direct injector-injectee relationship or extends transitively through module export graphs, and how this interacts with microservice or GraphQL execution contexts where request lifecycles differ from standard HTTP.
Does the implicit scope inheritance apply transitively across module export boundaries without explicit @Scope() annotations on consuming classes? Which conditions cause a default-scoped controller to be recreated per-request when a single request-scoped provider is introduced into its dependency tree? When does scope bubbling cross module boundaries, and are there strategies to confine request scope to intended handler boundaries without restructuring the entire dependency graph?
DEFAULT-scoped controller that injects a REQUEST-scoped provider — directly or transitively — becomes REQUEST-scoped at container resolution time, even if the provider comes from an imported module.@Injectable({ scope: Scope.REQUEST }). The DI container resolves scopes at bootstrap by walking the graph; the strictest scope in a chain wins.REQUEST-scoped provider. The exporting module's internal scopes are unaffected; only the importer's resolution graph changes.The injector builds a scope resolution graph during InstanceLoader bootstrap. In resolveScope() (see @nestjs/core/injector/container.ts), each provider's effective scope is the maximum of its declared scope and the scopes of all its dependencies. Module exports merely expose providers; they do not create scope firewalls.
REQUEST-scoped service directly.DEFAULT-scoped service that itself injects a REQUEST-scoped service (transitive).REQUEST-scoped provider originates in any imported module and is reachable through the import/export graph.All of the above cause the controller's entire dependency subtree to be instantiated per request, increasing GC pressure under high throughput.
@Controller({ scope: Scope.REQUEST }). Makes intent visible and prevents silent bubbling elsewhere.DEFAULT-scoped factory (useFactory) that resolves the REQUEST-scoped instance lazily per invocation, breaking the transitive chain.REQUEST-scoped providers are injected only into other REQUEST-scoped consumers. Keep DEFAULT-scoped services free of REQUEST dependencies.ModuleA exports a REQUEST-scoped service; ModuleB imports ModuleA and has a DEFAULT-scoped controller injecting that service.REQUEST injection or cls-hooked).ModuleB's controller.DEBUG=nestjs:* at startup to see scope resolution logs.Assumption: NestJS v10+. Behavior is stable but undocumented as a guarantee; treat as implementation detail subject to change.
Use comments to ask for clarification. Post a solution as an answer.
35,775 reputation · 28 Feb 2021, 05:00 UTC
The answer correctly identifies that scope bubbling is resolved at bootstrap via InstanceLoader. In NestJS 10+, you can observe this directly without a debugger: start the app with NestFactory.create(AppModule, { logger: ['debug'] }) and the container logs each provider's effective scope (e.g., Scope: REQUEST) as the resolution graph is built. This makes it trivial to confirm whether a DEFAULT-scoped controller has silently bubbled to REQUEST before any request hits the server.
For GraphQL or microservice (TCP/gRPC) transports, the same bubbling rules apply, but the request-scoped sub-container is created per execution context (per operation or message) rather than per HTTP request. The debug output still shows the bubbled scopes at bootstrap; the per-context instantiation occurs at runtime.