Skip to content

Modules and Dependency Injection

Aman Kumar Singh8 min read
Part 2 of 40From the NestJS Production Guide series
Modules and Dependency Injection — article by Aman Kumar Singh

Last time in the NestJS Architecture Overview, the first entry in the NestJS Production Guide, I laid out the layered shape a Nest app settles into: controllers, providers, modules, and the request lifecycle that ties them together. This article goes one level deeper into the piece that makes that shape actually hold: modules and the dependency injection container behind them.

Modules get treated as boilerplate by a lot of teams. You run nest g module users, get a class with a @Module() decorator, and move on. That's a mistake. The module is the unit NestJS uses to decide what a piece of code can see, and getting that wrong is how a codebase ends up with every service importable from everywhere.

I want to cover what a module boundary actually encapsulates, how the DI container resolves providers behind the scenes, when to reach for dynamic modules, and the circular dependency problems that show up once an app has more than a handful of modules talking to each other.

What a module boundary actually encapsulates

A NestJS module is a class annotated with @Module() that declares four things: providers it owns, controllers it exposes routes through, imports of other modules it depends on, and exports of its own providers that other modules are allowed to use.

// src/modules/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { DatabaseModule } from '../database/database.module';

@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService],
})
export class UsersModule {}

The important line here is exports. UsersRepository is a provider inside this module, but it's not exported, so no other module can inject it directly. Only UsersService is visible outside UsersModule's boundary. That's deliberate: other modules should talk to users through the service's public methods, not reach past it into the repository and start writing their own queries against a table they don't own.

This is the actual value of a module: it's an encapsulation boundary enforced by the framework. If a BillingModule needs something from UsersModule, it imports UsersModule and can only inject what UsersModule chose to export. Nothing in the DI container lets it reach past that. Compare this to a typical Express app where any file can require() any other file, and the only thing stopping tangled imports is discipline. NestJS gives you a mechanism, which matters once more than two or three engineers are touching the codebase.

How the DI container actually resolves a provider

Dependency injection in NestJS is constructor-based by default. When Nest instantiates UsersService, it looks at the constructor's parameter types, and for each one, asks: is there a matching provider registered somewhere this class can see?

// src/modules/users/users.service.ts
import { Injectable } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { Logger } from '@nestjs/common';

@Injectable()
export class UsersService {
  private readonly logger = new Logger(UsersService.name);

  constructor(private readonly usersRepository: UsersRepository) {}

  async findById(id: string) {
    this.logger.debug(`Fetching user ${id}`);
    return this.usersRepository.findOne(id);
  }
}

Under the hood, every provider gets registered against a token. By default that token is the class itself, UsersRepository. Nest matches on the exact token reference, not on shape. That's also why interfaces can't be injection tokens on their own: an interface disappears at compile time, so there's nothing left at runtime for Nest to look up. If you need to inject something abstract, whether that's an interface or a primitive config value, you need a separate token, usually a string or a Symbol, paired with a custom provider. That's the subject of the next article in this series, and it explains a confusing error message you'll run into sooner or later: "Nest can't resolve dependencies of X" almost always means a missing @Injectable() decorator, a provider that isn't registered in any module the consumer can see, or an attempt to inject an interface type directly.

Scope matters here too. By default every provider is a singleton: one instance per application, shared across every request. That's correct for stateless services like UsersService. It becomes a problem if you accidentally put per-request state, like a tenant ID pulled off the request, onto a singleton service, because that state leaks across concurrent requests. If you genuinely need request-scoped state, Nest supports @Injectable({ scope: Scope.REQUEST }), but every provider that depends on a request-scoped provider also becomes request-scoped, so Nest builds a new instance of that whole subtree per request instead of once at startup. Reach for request scope only when you actually need per-request isolation.

Feature modules, shared modules, and the root module

Most Nest apps end up with three kinds of modules. Feature modules, like UsersModule above, group a cohesive piece of domain logic: its controller, its services, its own repository. Shared modules export cross-cutting providers, things like a DatabaseModule wrapping the ORM connection, or a CacheModule wrapping a Redis client, that many feature modules need but that don't belong to any single feature. The root AppModule imports everything and is where the app's shape becomes visible at a glance.

// src/app.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from './modules/users/users.module';
import { BillingModule } from './modules/billing/billing.module';
import { DatabaseModule } from './modules/database/database.module';
import { AuthModule } from './modules/auth/auth.module';

@Module({
  imports: [DatabaseModule, AuthModule, UsersModule, BillingModule],
})
export class AppModule {}

A pitfall worth watching for: shared modules quietly becoming a dumping ground. Someone needs a DateUtilsService in one place, adds it to a SharedModule imported everywhere, and eventually SharedModule exports a dozen unrelated providers that have nothing in common except nobody decided where they belonged. Keep shared modules narrow and named for what they actually share, DatabaseModule, RedisModule, AuthModule. If a utility is genuinely stateless with no dependencies of its own, a plain exported function often serves better than wrapping it in a provider at all.

Dynamic modules for configurable dependencies

Static modules work fine until a module needs to be configured differently across environments, a Redis connection string that differs between staging and production, or a feature flag that changes which provider gets registered. NestJS's answer is the dynamic module pattern, the same one @nestjs/config and @nestjs/typeorm use with their forRoot() and forFeature() static methods.

// src/modules/cache/cache.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { CacheService } from './cache.service';
import { REDIS_OPTIONS } from './cache.constants';

export interface CacheModuleOptions {
  host: string;
  port: number;
  ttlSeconds: number;
}

@Module({})
export class CacheModule {
  static forRoot(options: CacheModuleOptions): DynamicModule {
    return {
      module: CacheModule,
      providers: [
        { provide: REDIS_OPTIONS, useValue: options },
        CacheService,
      ],
      exports: [CacheService],
      global: true,
    };
  }
}
// src/app.module.ts (excerpt)
CacheModule.forRoot({
  host: process.env.REDIS_HOST ?? 'localhost',
  port: Number(process.env.REDIS_PORT ?? 6379),
  ttlSeconds: 300,
}),

The global: true flag is worth calling out on its own. A global module's exports become available everywhere without every consumer needing to import it explicitly. That's convenient for a cache client half the app touches, but it's easy to overuse. Marking every module global erases the encapsulation benefit that made modules worth having. Reserve global: true for a small number of genuinely app-wide concerns, configuration and a shared cache client are reasonable candidates, and keep everything else importing explicitly so the dependency graph stays honest.

Circular dependencies and forwardRef

Once an app has enough modules, two of them will eventually need each other. OrdersModule needs something from UsersModule for order attribution, and UsersModule needs something from OrdersModule to show a user's order history. Import each other directly and Nest throws a circular dependency error at startup, because it can't decide which module to instantiate first.

forwardRef() is the escape hatch, on both sides of the cycle:

// src/modules/users/users.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { OrdersModule } from '../orders/orders.module';

@Module({
  imports: [forwardRef(() => OrdersModule)],
  // ...
})
export class UsersModule {}
// src/modules/orders/orders.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [forwardRef(() => UsersModule)],
  // ...
})
export class OrdersModule {}

It works, and sometimes it's genuinely the right call for two modules that legitimately need a small amount of information from each other. But treat a circular dependency as a signal worth investigating before reaching for forwardRef() as the default fix. Often it means a piece of shared logic, like "resolve a display name for an order," should live in a third module that both UsersModule and OrdersModule import, rather than in either module directly. Breaking the cycle by extracting a shared dependency keeps the graph a tree instead of a web, which is easier to reason about a year later when someone else is trying to figure out what depends on what.

Testing implications

The DI container is more than a runtime convenience. It's what makes NestJS's testing module genuinely useful. Test.createTestingModule() builds a real module graph, so you can override a single provider with a mock while every other wiring in the module behaves exactly as it does in production.

// src/modules/users/users.service.spec.ts
import { Test } from '@nestjs/testing';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';

describe('UsersService', () => {
  it('returns a user by id', async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UsersRepository,
          useValue: { findOne: jest.fn().mockResolvedValue({ id: '1', name: 'Ada' }) },
        },
      ],
    }).compile();

    const service = moduleRef.get(UsersService);
    const user = await service.findById('1');

    expect(user).toEqual({ id: '1', name: 'Ada' });
  });
});

This only works cleanly if your modules were structured with real boundaries in the first place. A service that reaches directly into another feature module's internals, bypassing exports, is much harder to isolate in a unit test, because you end up needing to stand up half the app just to test one method. Clean module boundaries and testability are really the same concern, just viewed from different angles.

Key takeaways

  • A module's `exports` array is an enforced encapsulation boundary, not documentation. Only export what other modules genuinely need to consume.
  • DI resolves providers by token, which defaults to the class reference. Interfaces alone can't be tokens because they don't exist at runtime.
  • Singleton scope is the right default for most providers; request scope adds real overhead to the entire dependency chain and should be reserved for cases that need per-request isolation.
  • Dynamic modules (`forRoot`/`forFeature`) are the standard way to make a module configurable; use `global: true` sparingly, for a small number of truly app-wide concerns.
  • A circular dependency between two modules is often a sign that shared logic belongs in a third module; reach for `forwardRef()` only after ruling that out.
  • Clean module boundaries are what make NestJS's testing module effective; a service that reaches past exports into another module's internals is harder to isolate in tests.

Frequently asked questions

What's the difference between a NestJS module and a provider?

A provider is an injectable class, like a service or repository, that does work. A module is a container that groups related providers and controllers, declares what it depends on through `imports`, and declares what it exposes through `exports`. Providers do the work; modules define who's allowed to see what.

Why does NestJS throw "cannot resolve dependencies" even though the provider exists?

This usually means the provider is registered in a module the consumer can't see, that the class is missing `@Injectable()`, or that you're trying to inject an interface type, which has no runtime representation for Nest to match against.

When should I use a dynamic module instead of a static one?

Reach for a dynamic module when a module's behavior needs to change based on configuration, an environment variable, a connection string, or a flag, rather than being fixed at compile time. If a module never needs configuration, a static `@Module()` is simpler and should stay that way.

Is it safe to make every shared module global?

No. Global modules skip the explicit `imports` requirement, which is convenient but erases the visibility boundary modules are meant to provide. Reserve `global: true` for a handful of genuinely app-wide concerns like configuration, and import everything else explicitly.

How do I fix a circular dependency between two feature modules without forwardRef?

Look at what each module actually needs from the other. Often it's a small piece of logic that neither module truly owns, and extracting it into a third, shared module that both import removes the cycle entirely instead of just working around it.

Does request-scoped injection affect performance?

It can. A request-scoped provider forces Nest to construct a new instance of it, and every provider that depends on it, on every incoming request, instead of once at application startup. For most services this overhead is negligible, but it's worth confirming you actually need per-request state before reaching for it as a default.

Related articles

CQRS in NestJS — Aman Kumar Singh
A NestJS Production Checklist — Aman Kumar Singh
Unit Testing Services — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.