The Request Context Nightmare: Why NestJS Struggles With It, and How Ditsmod Solved It Years Ago
The Request Context Nightmare: Why NestJS Struggles With It, and How Ditsmod Solved It Years Ago
If you have built production Node.js applications using NestJS, you have likely encountered this problem: How do you propagate request-scoped metadata (such as Request IDs, Correlation IDs, Tenant IDs, or authenticated User sessions) across your entire application?
Recently, a NestJS GitHub issue raised a compelling feature request: introducing a first-class, framework-level Request Context primitive (@nestjs/context). The motivation is clear. Enterprise applications need a reliable way to share contextual request data without:
- Manually passing variables down through every single method call.
- Converting large chunks of the dependency graph into request-scoped providers (which ruins performance).
- Depending on custom, fragile AsyncLocalStorage (CLS) wrappers that can lose context across event boundaries or RxJS streams.
As a developer of Ditsmod - a modular, Node.js web framework built on TypeScript and hierarchical Dependency Injection - I read this proposal with a smile. The exact feature NestJS developers are struggling to standardize is something Ditsmod solved years ago. And we did it without relying on AsyncLocalStorage as a core driver.
Here is a deep dive into why request context is such a hard problem in NestJS, and how Ditsmod's hierarchical DI solves it natively, elegantly, and with high performance.
The NestJS Dilemma: The Scope Bubble vs. AsyncLocalStorage
To understand why a first-class request context is difficult to implement in NestJS, we have to look at the two options NestJS developers have today.
Option A: Request-Scoped Providers (The "Scope Bubble")
NestJS allows you to register providers with scope: Scope.REQUEST. When a request comes in, NestJS resolves and instantiates a new instance of that provider. However, in NestJS, scopes propagate upwards.
[Request-Scoped Provider (e.g. RequestContext)]
โ (injected into)
[Singleton Service A] โ (automatically becomes Request-Scoped!)
โ (injected into)
[Singleton Service B] โ (automatically becomes Request-Scoped!)
If your logging service or database service injects a request-scoped RequestContext to retrieve a tenantId, those services instantly become request-scoped too. The "scope bubble" propagates all the way up the dependency graph.
Why is this bad?
- Performance Overhead: Resolving and instantiating large sub-trees of your dependency graph for every single HTTP request creates huge garbage collection pressure and increases API latency.
- Lost Singleton Caching: Services that should be stateless singletons are repeatedly recreated.
Option B: AsyncLocalStorage / CLS (The "Fragile Hack")
To avoid scope propagation, developers often wrap Node's native AsyncLocalStorage. They write middleware to seed the storage and then read from it inside singleton services. While this bypasses the scope propagation issue, it introduces new problems:
- No Lifecycle Guarantees: NestJS has a complex lifecycle (Middleware -> Guards -> Interceptors -> Pipes -> Controllers -> Interceptors -> Exception Filters). Ensuring AsyncLocalStorage context propagates correctly and consistently across all of these boundaries - especially when mixing HTTP, GraphQL, WebSockets, microservices, and RxJS-based streams - is notoriously difficult.
- RxJS Context Loss: Because NestJS interceptors rely heavily on RxJS streams, switching asynchronous contexts can cause AsyncLocalStorage to lose track of the current store, resulting in silent failures or mixed-up request contexts.
How Ditsmod Solves This Natively
Ditsmod stands for DI (Dependency Injection) + TS (TypeScript) + Mod (Modularity). Instead of a single global DI container attempting to mimic request scopes, Ditsmod is built from the ground up on Hierarchical Dependency Injection.
1. The Hierarchical DI Architecture
In Ditsmod, injectors are organized in a strict parent-child tree:
[ providersPerApp ] โ Global Singletons (Root)
โ
[ providersPerMod ] โ Module-level Singletons
โ
[ providersPerRou ] โ Route-level Singletons
โ
[ providersPerReq ] โ Request-level (Deepest Child)
Every incoming HTTP request in Ditsmod triggers the creation of a dedicated, lightweight request-level injector (DI container). Because the request injector is a child of the route, module, and app injectors, it can look up to resolve singletons, but singletons never look down to resolve request-scoped tokens.
2. First-Class RequestContext and Context Primitives
In @ditsmod/rest (the REST module), RequestContext is a standard request-scoped class registered in providersPerReq. It contains the raw request and response objects, automatically generates a unique requestId, and can easily be extended to hold custom metadata like authenticated user details or tenant IDs.
For arbitrary metadata propagation, @ditsmod/core provides a Context service. Here is how a request-scoped service or interceptor can use it:
import { injectable } from '@ditsmod/core';
import { RequestContext } from '@ditsmod/rest';
@injectable()
export class OrdersService {
// Directly inject the native request context
constructor(private ctx: RequestContext) {}
async findOrders() {
const requestId = this.ctx.requestId;
const tenantId = this.ctx.get('tenantId'); // Custom metadata set earlier
// Business logic...
}
}
Because OrdersService and RequestContext are both registered in providersPerReq, they belong to the same request-scoped container. There is no AsyncLocalStorage overhead, and the context is guaranteed to span the entire lifecycle of that request.
3. Fail-Fast Compilation: Preventing Scope Leaks
"But what if a singleton service accidentally tries to inject a request-scoped provider?"
In NestJS, this happens silently and converts your singleton into a request-scoped provider, hurting performance. In Ditsmod, the DI compiler checks the hierarchy at application bootstrap (compilation phase). If a singleton registered in providersPerMod tries to inject RequestContext (which only exists at the request level), Ditsmod will throw a clear compile-time error and refuse to start:
Error: No provider for RequestContext! (Resolution path: ModuleService -> RequestContext)
This strict boundary enforces clean, performant, and type-safe architecture:
- If a service must be a singleton, it cannot inject request-scoped data. You must pass request variables down as method parameters.
- If a service is request-scoped (like an auditor or session manager), you register it in
providersPerReq. It can injectRequestContextsafely.
This ensures you never accidentally degrade your application's performance.
4. Direct Parameter Context Passing and @ctx() Decorator
Ditsmod also supports passing the context directly to your controller methods as a parameter:
import { RequestContext, controller, route } from '@ditsmod/rest';
@controller()
export class HelloWorldController {
@route('GET')
tellHello(ctx: RequestContext) {
return `Hello! Your Request ID is: ${ctx.requestId}`;
}
}
If you want hierarchical context value, you can use the @ctx() decorator:
// request-scoped-bearer.guard.ts file
import { ctx, Context } from '@ditsmod/core';
import { CanActivate, guard, RequestContext } from '@ditsmod/rest';
import { AuthService } from './auth.service.js';
import { USER_SESSION, UserSession } from './types-and-constants.js';
@guard()
export class RequestScopedBearerGuard implements CanActivate {
constructor(
protected ctx: Context,
protected authService: AuthService
) {}
async canActivate(ctx: RequestContext, params?: any[]) {
// ...
const session = await this.authService.getSession(token);
this.ctx.set(USER_SESSION, session);
return Boolean(token);
}
}
// orders.service.ts file
class OrdersService {
// The DI engine retrieves the value associated with USER_SESSION from the Context handle
(@ctx(USER_SESSION) session: UserSession) {
return `Processing order for user: ${session.userId}`;
}
}
Conclusion: The Power of Hierarchical DI
The NestJS ecosystem is currently struggling to find a balance between the performance costs of request-scoped providers and the fragility of AsyncLocalStorage. Ditsmod shows that the root of the issue is not the lack of a wrapper, but the flat design of the DI container.
By introducing a strict hierarchical DI tree (Request -> Route -> Module -> App), request-scoped data becomes a first-class citizen:
- Performance: Only the lightweight request-level injector is instantiated per request. Singletons remain untouched.
- Safety: Compile-time checks prevent accidental scope leakage.
- Consistency: No context loss over event loops or RxJS streams.
If you are tired of fighting request context in NestJS, it might be time to look at how a framework built with hierarchical DI handles it out of the box. Check out the Ditsmod Framework on GitHub to learn more!
P.S. If you are using AI agents, you can install Ditsmod skills:
npx skills add ditsmod/agent-skills --skill '*' -y
Comments
No comments yet. Start the discussion.