How the Nest IoC container resolves providers

At its core, NestJS architecture is a dependency injection container organized by modules. Each provider is registered under an injection token. For a class provider, the token is the class itself. Other tokens can be strings or symbols. When the application bootstraps, Nest walks the module graph and works out what each class needs. It then creates the providers in dependency order and caches each instance in the module that owns it.

How Nest finds dependencies

TypeScript types are erased when code is compiled, so Nest cannot read a constructor's parameter types directly. It depends on two compiler options:

  • experimentalDecorators allows decorators such as @Injectable().
  • emitDecoratorMetadata makes the compiler record each decorated class's constructor parameter types as runtime metadata. Nest reads that metadata to match each parameter to a token.

If a parameter's type is an interface, there is nothing to record, because interfaces don't exist at runtime. In that case you name the token yourself with @Inject(). The same applies if your build tool doesn't emit decorator metadata. Not every transpiler does by default, so check your toolchain.

Scopes

By default, every provider is a singleton: one instance in each place it is registered, shared for the application's lifetime. Request and transient scopes let you change this for each provider. Both create instances more often, and both have a cost.

When wiring errors appear

The compiler does not check any of this. A missing provider or an unexported dependency compiles without errors. Nest reports it only when the application starts, with an error naming the class and the parameter it could not resolve. That means you need tests that boot the module graph.

Who benefits most

Backend and platform engineers get the most from this model, especially on TypeScript services that several teams work on or that will be maintained for years. Explicit module boundaries and replaceable dependencies are worth the extra ceremony in those codebases.

// tsconfig.json must enable both options so Nest can read constructor types:
//   "experimentalDecorators": true,
//   "emitDecoratorMetadata": true
import { Inject, Injectable, Module } from '@nestjs/common';

// Interfaces are erased at runtime, so this one needs its own token.
export interface PaymentGateway {
  charge(orderId: string, cents: number): Promise<void>;
}
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');

@Injectable()
export class SandboxGateway implements PaymentGateway {
  async charge(orderId: string, cents: number): Promise<void> {
    console.log(`sandbox charge ${orderId}: ${cents}`);
  }
}

@Injectable() // singleton by default: one instance shared within this module
export class OrdersService {
  constructor(
    // Class type: the token comes from emitted decorator metadata.
    private readonly sandbox: SandboxGateway,
    // Interface type: name the token explicitly with @Inject().
    @Inject(PAYMENT_GATEWAY) private readonly gateway: PaymentGateway,
  ) {}
}

@Module({
  providers: [
    SandboxGateway, // shorthand for { provide: SandboxGateway, useClass: SandboxGateway }
    { provide: PAYMENT_GATEWAY, useExisting: SandboxGateway },
    OrdersService,
  ],
  // Leave OrdersService out of exports and any importing module that injects it
  // still compiles, but Nest throws a resolution error at bootstrap.
  exports: [OrdersService],
})
export class OrdersModule {}

A module that registers one class-token provider and one symbol-token provider, showing which constructor parameters Nest resolves from emitted metadata and which need an explicit @Inject() token.

Modules as encapsulation boundaries, not folders

A Nest module is a visibility scope. A provider listed in a module's providers array can be injected only by other members of that same module. For another module to use it, two conditions must both hold: the declaring module lists the provider in exports, and the consuming module lists the declaring module in imports. Where the files live on disk has no effect on this.

Diagnosing "Nest can't resolve dependencies"

This error is nearly always a visibility failure. The message names three things: the class being constructed, the argument index that could not be resolved, and the module context where resolution was attempted. Diagnose it in this order:

  1. Find the module named in the message.
  2. Check whether that module provides the missing token itself.
  3. If not, check whether any module it imports exports that token.

Usually the export or the import is missing.

Avoid the tempting fix of adding the provider to the consumer's providers array. The error goes away, but the consumer now gets a second, separate instance. That causes problems for anything holding state, such as caches or connection pools.

What @Global() does

@Global() makes a module's exported providers injectable everywhere, without each consumer importing the module. You still need to export those providers, and you still register the module once, typically in the root module.

Reserve it for genuinely cross-cutting infrastructure like configuration or logging. Global modules and broad exports lists have the same cost: dependencies stop being declared at the point of use. You can no longer read a module's imports to learn what it depends on. Removing or changing a provider then becomes a search across the entire codebase instead of a check of known consumers.

import { Global, Injectable, Module } from '@nestjs/common';

// Stateful provider: one instance per module that registers it.
@Injectable()
export class RatesCache {
  private readonly entries = new Map<string, number>();
  get(key: string) { return this.entries.get(key); }
}

@Module({
  providers: [RatesCache],
  exports: [RatesCache], // condition 1: the declaring module exports the provider
})
export class RatesModule {}

@Injectable()
export class InvoiceService {
  constructor(private readonly rates: RatesCache) {}
}

@Module({
  imports: [RatesModule], // condition 2: the consumer imports the declaring module.
  // Remove this import and Nest throws "can't resolve dependencies of InvoiceService"
  // at argument index [0] in the InvoicesModule context.
  // Wrong fix: providers: [InvoiceService, RatesCache] creates a second, separate cache.
  providers: [InvoiceService],
})
export class InvoicesModule {}

// Global module: exported providers can be injected anywhere without an import.
// Register it once (in AppModule) and keep it for cross-cutting infrastructure.
@Injectable()
export class AppConfig {
  readonly region = 'eu-west-1';
}

@Global()
@Module({ providers: [AppConfig], exports: [AppConfig] }) // still has to export
export class ConfigCoreModule {}

@Module({ imports: [ConfigCoreModule, RatesModule, InvoicesModule] })
export class AppModule {}

RatesCache can be used outside RatesModule only because RatesModule exports it and InvoicesModule imports RatesModule, while the @Global() config module shows that a global module still has to export its providers and be registered once.

Provider forms, injection tokens and scopes

Every provider registration pairs a token with a recipe, and the recipe you choose determines how much the container does for you.

Form What the container does Use when
Class (shorthand) Instantiates the class and resolves its constructor parameters The default for services you own
useClass Instantiates a different class for the token Swapping implementations per environment or in tests
useValue Returns the given object as-is Constants, config objects, test doubles
useFactory + inject Calls the function with the listed dependencies, and awaits it if async Construction depends on config or other providers
useExisting Points the token at an already registered provider Exposing one instance under a second token

A TypeScript interface does not exist at runtime, so Nest cannot use it as a token. You register the implementation under a string or symbol and inject it with @Inject(TOKEN). Symbols cannot collide by accident, and string tokens can. Both cost you type safety, because the container does not check that the annotated parameter type matches what is bound to the token. A mismatch compiles cleanly and only fails when the code runs.

Scope matters more than form. DEFAULT gives one shared instance for the whole application. TRANSIENT gives each consumer its own instance. REQUEST builds a new instance for every incoming request. Request scope also propagates upward: any provider that injects a request-scoped provider becomes request-scoped too, and so does everything that depends on that provider, up to the controller. Inject a request-scoped logger deep in the graph and a large part of it gets rebuilt for every request. That adds allocation and GC pressure. It also silently breaks code that assumed a singleton, such as in-memory caches, counters, or connections opened in a constructor.

import { Inject, Injectable, Module, Scope } from '@nestjs/common';
import { randomUUID } from 'crypto';

// Interfaces vanish at runtime, so a symbol stands in as the token.
export interface PaymentGateway { charge(cents: number): Promise<string>; }
export const PAYMENT_GATEWAY = Symbol('PAYMENT_GATEWAY');
export const APP_CONFIG = Symbol('APP_CONFIG');
type AppConfig = { apiUrl: string; timeoutMs: number };

@Injectable()
class HttpGateway implements PaymentGateway {
  async charge(cents: number) { return `charged-${cents}`; }
}
class FakeGateway implements PaymentGateway {
  async charge() { return 'fake-charge'; }
}

// REQUEST scope: a new instance per incoming request.
@Injectable({ scope: Scope.REQUEST })
export class RequestLogger { readonly requestId = randomUUID(); }

@Injectable() // Declared DEFAULT, but it becomes request-scoped because it injects RequestLogger.
export class CheckoutService {
  constructor(
    // Nothing checks that the provider bound to PAYMENT_GATEWAY actually implements PaymentGateway.
    @Inject(PAYMENT_GATEWAY) private readonly gateway: PaymentGateway,
    private readonly log: RequestLogger,
  ) {}
}

@Module({
  providers: [
    RequestLogger, CheckoutService, // class shorthand
    { provide: APP_CONFIG, useValue: { apiUrl: '', timeoutMs: 3000 } },
    { provide: PAYMENT_GATEWAY, useClass: process.env.NODE_ENV === 'test' ? FakeGateway : HttpGateway },
    { provide: 'HTTP_TIMEOUT', useFactory: async (cfg: AppConfig) => cfg.timeoutMs, inject: [APP_CONFIG] },
    { provide: 'LEGACY_GATEWAY', useExisting: PAYMENT_GATEWAY }, // alias: same instance, second token
  ],
})
export class PaymentsModule {}

A NestJS module that registers providers in all five forms, binds an interface to a symbol token, and shows a request-scoped logger making its DEFAULT-scoped consumer request-scoped as well.

Dynamic modules and lifecycle hooks for shared infrastructure

That is one reason shared infrastructure such as database pools or HTTP clients should stay singleton and be packaged as a dynamic module. A static @Module() decorator fixes its providers at compile time. A dynamic module exposes a static method that returns a DynamicModule object containing module, providers, exports and optionally imports or global. This lets the caller pass configuration that becomes providers.

The naming convention tells callers how the module is meant to be used:

  • forRoot() is called once, usually in the root module. It creates the shared connection or client.
  • forFeature() is called per feature module. It registers scoped pieces, such as repositories for specific entities, on top of that shared connection.
  • register() creates a separately configured instance for each consumer, such as an HTTP client with a different base URL per downstream service.

Expose options under a dedicated injection token. Build the client with an async useFactory. Nest awaits that factory before it instantiates anything that depends on the client, so no service ever receives a half-connected client. ConfigurableModuleBuilder in @nestjs/common generates most of this boilerplate.

Resource-holding providers should implement onModuleInit and onModuleDestroy. Use onModuleInit to verify connectivity or run warm-up queries, and use onModuleDestroy to drain pools and close sockets. Nest awaits async hooks.

Destroy-phase hooks only run on process signals if you call app.enableShutdownHooks(). Without that call, pods that are terminated during deploys leak connections.

Ordering follows the import graph. An imported module's hooks run before those of the module that imports it. Nest does not guarantee hook order between providers in the same module, so do not rely on it.

import { DynamicModule, Inject, Injectable, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Pool, PoolConfig } from 'pg';

// Dedicated tokens so consumers depend on an abstraction, not a concrete class.
export const DB_OPTIONS = Symbol('DB_OPTIONS');
export const DB_POOL = Symbol('DB_POOL');

@Injectable()
class DbLifecycle implements OnModuleInit, OnModuleDestroy {
  constructor(@Inject(DB_POOL) private readonly pool: Pool) {}
  // Fail fast at startup if the database is unreachable.
  async onModuleInit() { await this.pool.query('SELECT 1'); }
  // Drain the pool so terminated pods do not leak connections.
  async onModuleDestroy() { await this.pool.end(); }
}

@Module({})
export class DatabaseModule {
  // Call once in the root module: DatabaseModule.forRoot({ host: 'db.internal', database: 'app' })
  static forRoot(options: PoolConfig): DynamicModule {
    return {
      module: DatabaseModule,
      global: true, // one shared pool for the whole app
      providers: [
        { provide: DB_OPTIONS, useValue: options },
        {
          provide: DB_POOL,
          inject: [DB_OPTIONS],
          // Nest awaits this before instantiating anything that injects DB_POOL.
          useFactory: async (opts: PoolConfig) => new Pool(opts),
        },
        DbLifecycle,
      ],
      exports: [DB_POOL],
    };
  }
}

// main.ts: without this, onModuleDestroy does not run on SIGTERM.
// const app = await NestFactory.create(AppModule);
// app.enableShutdownHooks();

A forRoot() dynamic module that turns caller options into a singleton pg pool behind an injection token, checks connectivity in onModuleInit, drains the pool in onModuleDestroy, and relies on enableShutdownHooks() so that teardown runs when the process receives a shutdown signal.

Circular dependencies: what forwardRef() fixes and what it hides

Cycles show up in two places. Between providers, OrdersService injects PaymentsService, and PaymentsService later injects OrdersService to read order state. Between modules, OrdersModule imports PaymentsModule and PaymentsModule imports it back. Barrel files can also create a cycle at the TypeScript import level. In that case a class reference is undefined when the decorator metadata is evaluated, before Nest's container gets involved at all.

forwardRef() defers that reference. Use forwardRef(() => PaymentsModule) in imports, and @Inject(forwardRef(() => PaymentsService)) in the constructor. Both sides of the cycle need it. It lets the container build the graph, but it doesn't remove the cycle. Nest doesn't guarantee which side is instantiated first. If a constructor calls a method on its forward-referenced dependency, that dependency may not be ready. Because nothing fails at compile time, the cycle stays in the code and new ones become easier to add.

In most cases the cycle means a responsibility has been put in the wrong place. There are three ways to fix it:

  • Extract a shared module. Move the logic both services need, such as order-state lookups, into an OrderQueryModule that both modules import.
  • Invert the dependency behind a token. PaymentsService depends on an ORDER_READER interface token. OrdersModule provides that token, so PaymentsModule never imports OrdersModule.
  • Decouple with events. PaymentsService emits payment.settled, and an orders listener reacts to it. The two modules no longer reference each other.

In the refactor example that follows, both modules import each other through forwardRef() before the change. After it, the dependency graph runs in one direction: Orders → Payments → OrderQuery, and there are no forwardRef() calls left.

A practical module layout and testing with the DI container

For a mid-sized service, a layout that holds up well has three tiers. Feature modules (OrdersModule, PaymentsModule, CatalogModule) each own their controllers, services and repositories, and export only the providers other features need. A CoreModule holds shared infrastructure: the database client, HTTP clients, config and logging. Features import it rather than each constructing their own clients. The root AppModule only composes. It imports modules and declares nothing of its own, so any provider found there is a sign that something has not been given a proper home.

Module granularity is a judgment call. Very fine-grained modules, such as one per service, add import boilerplate and make export lists noisy without isolating anything meaningful. Very coarse modules, such as a single DomainModule, remove the boundary entirely. A reasonable test: if two groups of providers change for different reasons and are consumed by different callers, they belong in separate modules.

The payoff shows up in tests. Test.createTestingModule() builds a real container from whatever metadata you pass it. You can then chain .overrideProvider(PaymentGateway).useValue(fakeGateway) before .compile() to replace a dependency while every other wiring decision stays exactly as it runs in production. When PaymentsModule imports only CoreModule and exports a narrow surface, its test module needs one or two overrides. When boundaries are leaky, the same test pulls in half the application, and you end up overriding providers you never meant to touch. Much of the time saved comes from keeping that graph small, more than from the mocking itself.

When to avoid it, alternatives, and impact on engineering cost

The container pays for itself only when there is a graph worth managing. For a single-purpose worker, a CLI script or a service with five classes, modules and providers add indirection without adding safety. The coupling also matters. Nest depends on TypeScript decorators and emitted type metadata, so your compiler settings and build pipeline become part of the architecture. Some fast transpilers do not emit that metadata by default. Each one then needs extra configuration, or you fall back to explicit @Inject() tokens everywhere.

Approach Wiring Boundaries Test setup Main cost
Nest modules + DI Container resolves the graph Enforced by exports/imports Override providers in a testing module Decorators, metadata, learning curve
Function composition, manual constructors A hand-written composition root By convention only Pass fakes as arguments Wiring grows with the graph and drifts
Light HTTP framework + standalone DI library Library container Usually flat, with no module scoping Varies by library You assemble lifecycle and conventions yourself

Illustrative estimate (assumed numbers, not a benchmark). Break-even months = U ÷ (s × c). Here U is the upfront hours spent on learning and scaffolding, s is the hours saved per change on wiring and test setup, and c is the number of changes per month that touch the dependency graph. If U = 40, s = 0.5 and c = 20, then 40 ÷ 10 = 4 months. If c = 3, which is typical of a small, stable service, the result is about 27 months, and manual wiring is the cheaper choice.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to nestjs architecture, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.