Dependency injection in NestJS: best practices explained. How the container resolves your providers — and the scopes that bite.
Understanding Dependency Injection in NestJS
Dependency injection in NestJS is often treated as magic—you declare what you need, the framework delivers it, and your code works. But that magic comes with invisible costs: circular dependencies, memory bloat, and request leaks that only surface under load. Understanding how the container resolves your providers — and the scopes that bite — is the difference between a prototype that scales and production code that doesn't.
In this guide, we'll walk through constructor injection, provider scopes, and the performance tradeoff that catches most teams. We'll show you what works in production and what will silently drain your database connections.
Constructor Injection: How Nest Resolves Your Dependencies
NestJS uses constructor injection as its primary dependency resolution mechanism. When you declare a dependency in your constructor, the container matches it by type token—usually the class itself—and injects an instance of that provider.
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserService {
constructor(private readonly db: DatabaseConnection) {}
async findById(id: string) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
@Injectable()
export class UserController {
constructor(private readonly userService: UserService) {}
async getUser(id: string) {
return this.userService.findById(id);
}
}The container sees that UserController depends on UserService, which depends on DatabaseConnection. It resolves the chain: create or retrieve an instance of DatabaseConnection, pass it to UserService, then pass the service to UserController.
This works because each provider is registered with a token—usually the class itself. You can also use string tokens or symbols for abstract dependencies:
const DB_TOKEN = 'DATABASE';
// In your module
@Module({
providers: [
{
provide: DB_TOKEN,
useValue: new DatabaseConnection(),
},
{
provide: UserService,
inject: [DB_TOKEN],
useFactory: (db: DatabaseConnection) => new UserService(db),
},
],
})
export class UserModule {}The key insight: resolution is declarative. The container knows what you need before instantiation. That predictability is what makes dependency injection powerful, but it's also where scope—the lifetime of an instance—becomes critical.
Provider Scopes: DEFAULT, REQUEST, and TRANSIENT
NestJS offers three provider scopes. Each controls how long an instance lives and how many times it's created.
DEFAULT (Singleton)
By default, providers are singletons. A single instance is created when the application starts and shared across every request, every consumer, for the lifetime of the app.
@Injectable()
export class CacheService {
private store = new Map();
set(key: string, value: any) {
this.store.set(key, value);
}
get(key: string) {
return this.store.get(key);
}
}
// Registered with DEFAULT scope (implicit)
@Module({
providers: [CacheService],
})
export class CacheModule {}One CacheService instance handles all requests. This is efficient: you initialize expensive resources once, reuse them forever. Perfect for caches, database connections, and HTTP clients.
REQUEST
REQUEST scope creates a new instance for every HTTP request. When the request ends, the instance is garbage collected.
import { Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestIdService {
private id: string;
constructor() {
this.id = generateId();
}
getId() {
return this.id;
}
}
@Module({
providers: [RequestIdService],
})
export class RequestModule {}Each incoming request gets its own RequestIdService instance with a unique ID. You can log this ID to trace a request through your system.
TRANSIENT
TRANSIENT scope creates a new instance every time a dependency is injected. If five providers depend on a TRANSIENT service, you get five separate instances.
@Injectable({ scope: Scope.TRANSIENT })
export class OperationTracker {
private operationId: string;
constructor() {
this.operationId = generateId();
}
log(message: string) {
console.log(`[${this.operationId}] ${message}`);
}
}
// In UserService (singleton)
@Injectable()
export class UserService {
constructor(private readonly tracker: OperationTracker) {}
async createUser(data: any) {
// This tracker is a different instance than the one in OrderService
this.tracker.log('Creating user');
}
}
// In OrderService (singleton)
@Injectable()
export class OrderService {
constructor(private readonly tracker: OperationTracker) {}
async createOrder(data: any) {
// Separate instance
this.tracker.log('Creating order');
}
}TRANSIENT is rarely needed. It's useful for stateful utilities where you want isolation, but it's a memory and garbage collection burden.
| Scope | Instances | Lifetime | Use Case |
|---|---|---|---|
| DEFAULT | One app-wide | App startup to shutdown | Caches, DB connections, HTTP clients |
| REQUEST | One per HTTP request | Request arrival to response send | Request context, user info, tracing |
| TRANSIENT | One per consumer | Resolved on each injection | Stateful isolated utilities (rare) |
Request Scope Bubbles Up: The Hidden Cost
Here's the critical behavior that derails production systems: if any provider in a dependency chain is REQUEST-scoped, the entire chain becomes REQUEST-scoped.
Imagine you have a singleton service that depends on a request-scoped service:
@Injectable({ scope: Scope.REQUEST })
export class UserContextService {
private userId: string;
setUserId(id: string) {
this.userId = id;
}
getUserId() {
return this.userId;
}
}
@Injectable()
export class UserService {
// This becomes REQUEST-scoped, even though we didn't mark it
constructor(private readonly context: UserContextService) {}
async findById(id: string) {
return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
}
}
@Injectable()
export class AnalyticsService {
// This also becomes REQUEST-scoped
constructor(private readonly userService: UserService) {}
async trackEvent(event: string) {
console.log('Event:', event);
}
}The container sees that UserService depends on a REQUEST provider. To ensure safety—each request gets its own context—NestJS promotes UserService to REQUEST scope too. Then AnalyticsService depends on UserService, so it's also promoted.
What was supposed to be one or two request-scoped services becomes an entire tree of request-scoped services. Every single HTTP request creates new instances of services that should be singletons. Your application suddenly creates millions of objects per day. Database connection pools are exhausted. Garbage collection pauses spike. P99 latency goes through the roof.
This is not a memory leak—it's worse. It's an architectural decision hiding in your code, invisible until you profile production.
Prefer Singletons + Context: The Production Pattern
The solution: keep your services singleton and read request-specific data from context. NestJS provides REQUEST scope, but most production code should avoid it.
Use Node.js's AsyncLocalStorage (or NestJS's @Inject() with middleware) to attach request context to the async chain without changing provider scope.
import { AsyncLocalStorage } from 'async_hooks';
export interface RequestContext {
userId: string;
traceId: string;
requestId: string;
}
const requestContext = new AsyncLocalStorage();
@Injectable()
export class ContextService {
static setContext(ctx: RequestContext) {
requestContext.enterWith(ctx);
}
static getContext(): RequestContext | undefined {
return requestContext.getStore();
}
getUserId(): string | undefined {
return this.getContext()?.userId;
}
getTraceId(): string | undefined {
return this.getContext()?.traceId;
}
}
// Use a middleware to set context on every request
@Injectable()
export class ContextMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
const traceId = generateId();
const userId = extractUserFromToken(req);
ContextService.setContext({
userId,
traceId,
requestId: req.id,
});
next();
}
}
// Consumers remain singleton
@Injectable()
export class UserService {
constructor(private readonly context: ContextService) {}
async createUser(data: any) {
const userId = this.context.getUserId();
console.log(`User ${userId} creating new user`);
// ... database call ...
}
}
@Injectable()
export class AnalyticsService {
constructor(private readonly context: ContextService) {}
async trackEvent(event: string) {
const traceId = this.context.getTraceId();
console.log(`[${traceId}] Event: ${event}`);
}
}Now UserService and AnalyticsService remain singletons. One instance per app. They read request context from AsyncLocalStorage at call time. No scope bubbling. No memory explosion. The async context is automatically isolated per request thanks to Node's event loop.
This pattern is used in production by teams handling millions of requests daily across e-commerce, ride-hailing, and SaaS platforms.
Common Mistakes
- Making everything REQUEST-scoped "just to be safe". This kills performance. Only mark services REQUEST if they truly need per-request isolation (like a request ID generator). Use context instead.
- Not noticing scope bubbling in large modules. A single REQUEST dependency can silently promote dozens of services. Use NestJS's debug logs (
LOG_LEVEL=debug) to spot it. - Storing mutable state in singleton services. If your singleton service has instance variables that change per request, you'll leak data between requests. Always read request-specific data from context, not instance state.
- Using TRANSIENT for everything. TRANSIENT is for stateful utilities that need isolation per consumer—very rare. Default to singleton, use REQUEST only when necessary.
- Circular dependencies in constructor injection. If Service A depends on B and B depends on A, NestJS fails to resolve. Use
@Inject(forwardRef(() => ServiceA))to break the cycle, but consider whether your design needs restructuring.
Key Takeaways
- Constructor injection is declarative: the container matches dependencies by token and resolves them before instantiation.
- DEFAULT (singleton) scope means one instance app-wide. Most services should be singletons.
- REQUEST scope creates a new instance per HTTP request. Use sparingly—only for services that genuinely need per-request data.
- TRANSIENT scope creates a new instance per consumer. Rare. Avoid unless you need hard isolation.
- REQUEST-scoped dependencies bubble up: if a singleton depends on a REQUEST service, the singleton becomes REQUEST-scoped too. This silently kills performance.
- The production pattern: keep services singleton and read request context from
AsyncLocalStoragevia middleware. No scope bubbling. No memory bloat. Clean isolation. - Always profile your application's instance creation under realistic load. If you see thousands of provider instances per second, scope bubbling is likely the culprit.






Comments
Loading comments…