Injection Scopes

Understand DEFAULT, REQUEST and TRANSIENT scopes, why scope bubbles up, and the real cost of leaving singletons behind.

By default every provider is a singleton: Nest builds it once and shares that instance for the life of the app. This is what makes Nest fast, and it is almost always what you want. Scopes let you opt out when you genuinely need per-request state.

The three scopes

  • DEFAULT (singleton) - one instance, shared everywhere. The right choice 95% of the time.
  • REQUEST - a new instance per incoming request. Handy for request-scoped context like the current user or a per-request DB transaction.
  • TRANSIENT - a fresh instance for every consumer that injects it. No sharing at all.
@Injectable({ scope: Scope.REQUEST })
export class RequestContext {
  userId?: string;
}

Scope bubbles up (the gotcha)

If a singleton injects a REQUEST-scoped provider, the singleton is promoted to REQUEST scope too, and so is anything that injects it. One request-scoped provider deep in a controller can quietly turn a whole chain per-request. Nest logs this at startup if you enable it, but you should reason about it up front.

Why you should care about the cost

Request-scoped providers are instantiated on every request, so the DI container does more work and you lose the ability to inject them into truly global places (like a custom lifecycle hook that runs once). Treat REQUEST scope as a scalpel, not a default.

Reaching the request when you must

Inside a REQUEST-scoped provider you can inject the original request object with @Inject(REQUEST). For most "who is the current user" needs, though, an AsyncLocalStorage-backed context service keeps your providers singletons and avoids the scope cascade entirely.

Example

Example Β· typescript
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

// REQUEST-scoped: one instance per HTTP request.
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  readonly requestId: string;
  readonly startedAt = Date.now();

  constructor(@Inject(REQUEST) private readonly req: Request) {
    this.requestId =
      (req.headers['x-request-id'] as string) ?? crypto.randomUUID();
  }

  elapsedMs() {
    return Date.now() - this.startedAt;
  }
}

// WARNING: injecting the service above promotes THIS service
// (and its consumers) to REQUEST scope as well.
@Injectable()
export class AuditService {
  constructor(private readonly ctx: RequestContextService) {}

  record(action: string) {
    return {
      action,
      requestId: this.ctx.requestId,
      tookMs: this.ctx.elapsedMs(),
    };
  }
}

// TRANSIENT: every consumer gets its own copy β€” useful for a
// logger that stamps each host class with its own label.
@Injectable({ scope: Scope.TRANSIENT })
export class ScopedLogger {
  private label = 'app';
  setContext(label: string) {
    this.label = label;
  }
  log(msg: string) {
    console.log(`[${this.label}] ${msg}`);
  }
}

When to use it

  • A developer marks a request-scoped service as REQUEST so it gets a fresh instance per HTTP request and can safely store request-specific data like the current user.
  • A team avoids REQUEST scope on performance-critical services after learning that request-scoped providers force their entire dependency chain to be recreated per request.
  • An engineer uses TRANSIENT scope for a lightweight builder class that must start fresh each time it is injected, even within the same request.

More examples

Default singleton scope

Shows an explicit Scope.DEFAULT declaration, confirming that the same CacheService instance is shared across all injections.

Example Β· ts
import { Injectable, Scope } from '@nestjs/common';

// Scope.DEFAULT is the default β€” only one instance for the app lifetime
@Injectable({ scope: Scope.DEFAULT })
export class CacheService {
  private store = new Map<string, unknown>();
  set(k: string, v: unknown) { this.store.set(k, v); }
  get(k: string) { return this.store.get(k); }
}

Request-scoped provider

Creates a REQUEST-scoped service that receives the raw HTTP request via @Inject(REQUEST), giving safe per-request user context.

Example Β· ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  constructor(@Inject(REQUEST) private req: Request) {}

  getUser() {
    return (this.req as any).user;  // populated by JWT guard
  }
}

Transient scope for fresh instances

Uses Scope.TRANSIENT so each injection site gets its own fresh QueryBuilderService with an empty clauses array.

Example Β· ts
import { Injectable, Scope } from '@nestjs/common';

// A new instance is created every time this provider is injected
@Injectable({ scope: Scope.TRANSIENT })
export class QueryBuilderService {
  private clauses: string[] = [];

  where(clause: string) { this.clauses.push(clause); return this; }
  build() { return this.clauses.join(' AND '); }
}

Discussion

  • Be the first to comment on this lesson.