· frameworks  · 7 min read

Unlocking the Power of Dependency Injection in NestJS

A deep dive into NestJS's dependency injection system: how it works, advanced techniques (custom providers, scopes, ModuleRef), common pitfalls and fixes, and practical patterns to produce cleaner, more testable code.

A deep dive into NestJS's dependency injection system: how it works, advanced techniques (custom providers, scopes, ModuleRef), common pitfalls and fixes, and practical patterns to produce cleaner, more testable code.

Outcome first: after reading this article you’ll be able to design services and modules in NestJS that are easier to test, swap implementations without touching consumers, avoid common DI pitfalls (including circular dependencies and scope surprises), and leverage advanced provider patterns like factories, tokens, and ModuleRef for real-world needs.

Why this matters. Dependency injection (DI) is more than a convenience in NestJS - it’s the foundation for maintainable, decoupled architecture. When used well, DI lets you swap implementations, mock easily in tests, and keep classes single-responsibility. When used poorly, you get mysterious runtime errors, performance problems, and brittle code.

What follows is a practical, example-driven deep dive into NestJS DI: core concepts, advanced techniques, common pitfalls (and fixes), testing strategies, and recommended patterns.

Quick recap: how DI works in NestJS (in one paragraph)

NestJS builds a dependency graph from providers (classes, values, factories) registered in modules. When a consumer requests a dependency via constructor injection, Nest resolves the provider from the module graph, honoring provider tokens, scope, and imports/exports between modules. Providers are singletons by default unless a scope is specified.

For the official reference, see the NestJS providers documentation: https://docs.nestjs.com/providers

The basics - providers and constructor injection

A provider is any injectable class, value, or factory that can be injected into consumers. The most common pattern:

@Injectable()
export class UsersService {
  constructor(private readonly repo: UsersRepository) {}
}

Nest will find a provider matching the type UsersRepository and inject it. Important rules:

  • Classes must be decorated with @Injectable() (or a controller/module decorator when appropriate).
  • The provider must be registered in the same module or an imported module and exported if needed by another module.

Common mistake: forgetting to add a provider to a module’s providers array or to export it when needed elsewhere. If Nest can’t find the provider, you’ll get a runtime error.

Custom providers: useClass, useValue, useFactory, useExisting

Beyond class providers you can define providers explicitly to enable swapping implementations or creating instances with runtime values.

  • useClass - provide a different class under a token.
  • useValue - provide a literal value (useful for config or constants).
  • useFactory - run a factory function (can be async) and inject other providers into the factory.
  • useExisting - alias an existing provider under a new token.

Example: swapping payment gateways behind a token:

export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');

@Module({
  providers: [
    {
      provide: PAYMENT_GATEWAY,
      useClass: StripeGateway, // could swap to PaypalGateway later
    },
  ],
  exports: [PAYMENT_GATEWAY],
})
export class PaymentModule {}

@Injectable()
export class OrdersService {
  constructor(@Inject(PAYMENT_GATEWAY) private gateway: PaymentGateway) {}
}

Or using useFactory when creation needs async config:

{
  provide: PAYMENT_GATEWAY,
  useFactory: async (configService: ConfigService) => {
    const opts = await configService.getGatewayOptions();
    return new StripeGateway(opts);
  },
  inject: [ConfigService],
}

Docs on custom providers: https://docs.nestjs.com/fundamentals/custom-providers

Tokens and interfaces - the TypeScript caveat

TypeScript interfaces vanish at runtime. You cannot use an interface as a DI token. Use one of:

  • The class itself (works when you have concrete classes).
  • A string token.
  • A Symbol token (recommended to avoid collisions).
  • An abstract class (retains runtime identity).

Example using a symbol token:

export const CACHE_MANAGER = Symbol('CACHE_MANAGER');

providers: [
  { provide: CACHE_MANAGER, useValue: new MyCache() }
]

constructor(@Inject(CACHE_MANAGER) private cache: Cache) {}

Scopes: Singleton vs Request vs Transient - and why they matter

Providers are singleton by default. Nest supports three scopes:

  • DEFAULT (singleton)
  • REQUEST - a new instance per request (useful for per-request state)
  • TRANSIENT - every injection gets a new instance

Example:

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  id = Math.random().toString(36).slice(2);
}

Common pitfall: mixing request-scoped and singleton providers. If a singleton depends on a request-scoped provider, Nest must create request-scoped instances lazily and this affects performance and may require using ModuleRef or injecting the provider dynamically. Avoid making expensive/shared singletons depend on a request-scoped service unless necessary.

See injection scopes: https://docs.nestjs.com/fundamentals/injection-scopes

Circular dependencies and forwardRef

When two providers depend on each other, you get a circular dependency. Fixes:

  • Reconsider design. Often the presence of a circular dependency signals a violation of single responsibility. Extract a new provider that both can depend on.
  • If unavoidable, use forwardRef in modules or providers.

Example with modules:

@Module({ imports: [forwardRef(() => UserModule)] })
export class AuthModule {}

@Module({ imports: [forwardRef(() => AuthModule)] })
export class UserModule {}

Example with providers:

@Injectable()
export class AService {
  constructor(@Inject(forwardRef(() => BService)) private b: BService) {}
}

Docs: https://docs.nestjs.com/fundamentals/circular-dependency

ModuleRef: dynamic resolution and advanced patterns

ModuleRef lets you resolve providers at runtime rather than via constructor injection. Use cases:

  • Lazy resolution to avoid circular issues.
  • Resolving request-scoped providers inside a singleton when you need per-request data.
  • Factories that need to create instances dynamically.

Example:

@Injectable()
export class LazyService {
  constructor(private moduleRef: ModuleRef) {}

  async doSomething() {
    const svc = await this.moduleRef.resolve(SomeService);
    return svc.act();
  }
}

ModuleRef methods: get, resolve. Use resolve for request-scoped providers.

ModuleRef docs: https://docs.nestjs.com/fundamentals/module-ref

Lifecycle hooks and provider cleanup

Providers can implement lifecycle hooks: OnModuleInit, OnModuleDestroy, beforeApplicationShutdown. Use them to initialize or cleanup resources (like DB connections or timers).

@Injectable()
export class CacheService implements OnModuleInit, OnModuleDestroy {
  onModuleInit() {
    this.client.connect();
  }

  onModuleDestroy() {
    this.client.disconnect();
  }
}

Docs: https://docs.nestjs.com/fundamentals/lifecycle-events

Testing and mocking - DI is your friend

Good DI design makes tests trivial. Use createTestingModule to override providers:

const moduleRef = await Test.createTestingModule({
  providers: [UsersService, UsersRepository],
})
  .overrideProvider(UsersRepository)
  .useValue({ find: jest.fn().mockResolvedValue([]) })
  .compile();

const usersService = moduleRef.get(UsersService);

You can also override custom provider tokens with useValue or useFactory.

Testing docs: https://docs.nestjs.com/fundamentals/testing

Common pitfalls and how to fix them

  • Forgetting to add provider to module providers: add it or import and export from other module.
  • Trying to inject a TypeScript interface: use a symbol or abstract class instead.
  • Circular dependency errors: refactor, extract a shared service, or use forwardRef (sparingly).
  • Unexpected provider scope behavior: check if a provider is request-scoped and ensure consumers expect per-request instances.
  • Using useFactory without inject: your factory won’t have dependencies and may require manual access to ConfigService; add inject array.
  • Memory/performance issues from excessive request-scoped providers: prefer singletons and keep request-scoped providers minimal.

Patterns to leverage DI for cleaner code

  • Adapter pattern with tokens: hide third-party implementations behind a token and swap implementations per environment (mock in tests, real in prod).

  • Feature toggles via factory providers: use useFactory with ConfigService to select implementations at runtime.

  • Facades: create a single facade provider that groups several internal providers and exports a simple API for consumers. Keeps consumers decoupled from many dependencies.

  • Per-request context service: attach request metadata (like correlation ID or user ID) to a small request-scoped provider and inject it where needed. Keep it lean to avoid performance impacts.

Practical example: pluggable storage with DI

Imagine you want a StorageService that can use local filesystem in dev and S3 in prod. With DI:

export const STORAGE = Symbol('STORAGE');

@Module({
  providers: [
    {
      provide: STORAGE,
      useFactory: (config: ConfigService) =>
        config.get('USE_S3')
          ? new S3Storage(config.get('S3'))
          : new LocalStorage(),
      inject: [ConfigService],
    },
  ],
  exports: [STORAGE],
})
export class StorageModule {}

@Injectable()
export class FilesService {
  constructor(@Inject(STORAGE) private storage: StorageInterface) {}
}

Now swapping implementations is just a config change.

When to avoid DI (or simplify)

Not every tiny helper needs to be a provider. Use DI for things that:

  • Cross module boundaries
  • Have multiple implementations
  • Need lifecycle management
  • Need configuration or runtime composition

For tiny stateless helpers used inside a single class, plain functions can be simpler and clearer.

Best practices checklist

  • Prefer tokens (Symbols) or abstract classes when the consumer should not depend on a concrete implementation.
  • Keep request-scoped providers minimal. Favor singleton services when possible.
  • Avoid circular dependencies by refactoring; use forwardRef only as a last resort.
  • Use useFactory for runtime initialization and asynchronous construction.
  • Leverage ModuleRef for dynamic or late resolution but keep constructor injection as the default for clarity.
  • Use provider overrides in tests to isolate units and make tests deterministic.
  • Implement lifecycle hooks for resource initialization and cleanup.

Final thoughts - make DI your design tool

DI in NestJS is not just a way to reduce new expressions - it’s a design surface. Use it intentionally. Encapsulate implementation details behind tokens. Configure behavior at module composition time. Keep providers single-responsibility and testable. When you structure your application around clear provider boundaries, modules become replaceable, tests become easier, and features become simpler to evolve.

If you take one idea away: design your dependencies, don’t let them accumulate. Explicit tokens, controlled scopes, and factory providers give you a predictable, maintainable architecture. Use DI as the tool to achieve that.

References

Back to Blog

Related Posts

View All Posts »