Skip to main content

What are modules in NestJS and how do they work?

NestJS module is a TypeScript class decorated with @Module() that groups related controllers, providers, and sub-modules into a dependency injection scope.

Theory

TL;DR

  • Modules are like shipping containers: each holds its cargo (controllers, providers), declares what it needs from others (imports), and what it shares outward (exports)
  • Providers in a module are private by default - nothing leaks out unless you explicitly list it in exports
  • One feature = one module (UsersModule, OrdersModule). AppModule just imports them
  • providers registers a class in this module's DI scope; exports makes a subset available to importers
  • Circular imports need forwardRef()

Quick example

typescript
// users/users.module.ts @Module({ controllers: [UsersController], // handles /users routes providers: [UsersService], // scoped to this module only exports: [UsersService], // other modules can now inject this }) export class UsersModule {} // app.module.ts @Module({ imports: [UsersModule], // pulls in UsersModule's exports }) export class AppModule {}

NestJS scans AppModule, loads UsersModule, and wires UsersService into UsersController at startup. No manual instantiation. The /users endpoints go live.

Dependency scope

Providers live only inside the module that declares them. If EmailsModule needs UsersService, it must import UsersModule, and UsersModule must export UsersService. That contract is explicit and checked at startup, not at request time.

Compare this to plain Express, where you require() anything from anywhere. Fine at small scale, hard to trace at large scale. NestJS makes ownership visible.

Module types

Feature module groups one domain area. It has its own controllers and services, and exports what other modules might need.

Shared module provides common utilities across the app. Put DatabaseService, EmailService, or LoggerService here and export them. Any feature module that imports SharedModule gets access.

Global module (decorated with @Global()) registers providers app-wide, so no module needs to explicitly import it. Useful for config or logging. But used widely, it hides dependencies - you can no longer tell from a module's imports what it actually uses.

Dynamic module accepts runtime configuration. TypeOrmModule.forRoot(options) is the classic example. The static method returns a DynamicModule object instead of a class, letting you pass database credentials, API keys, or file paths at import time.

How the module graph is built

At startup, NestJS reads all @Module() decorators and builds a directed acyclic graph (DAG) of dependencies. It resolves imports recursively until the full tree is mapped. Then it instantiates providers using TypeScript metadata reflection via reflect-metadata.

The IoC container matches @Inject() tokens to providers within the resolved scope. If a token is missing, you get UnknownInjectionToken before any request is handled. That early failure is intentional - you find broken wiring at boot, not in production at 3am.

Dynamic module pattern in practice

typescript
// config/config.module.ts import { DynamicModule, Module } from '@nestjs/common'; import { ConfigService } from './config.service'; @Module({}) export class ConfigModule { static forRoot(configPath: string): DynamicModule { return { module: ConfigModule, global: true, // injectable everywhere, no explicit imports needed providers: [ { provide: 'CONFIG_PATH', useValue: configPath }, ConfigService, ], exports: [ConfigService], }; } } // app.module.ts @Module({ imports: [ConfigModule.forRoot('prod.env')], }) export class AppModule {}

global: true makes ConfigService injectable everywhere. Without it, only modules that explicitly import ConfigModule can use the service. Both approaches work - choose based on how widely the service is needed.

Common mistakes

Forgetting exports is the most common error, especially after moving a service to a new module:

typescript
// users.module.ts - WRONG: no exports @Module({ providers: [UsersService] }) export class UsersModule {} // emails.module.ts imports UsersModule, but: constructor(private usersService: UsersService) {} // Error: Nest can't resolve dependencies of EmailsService. // No provider for UsersService!

Fix: add exports: [UsersService] to UsersModule. Importing a module alone gives you nothing.

Circular dependencies occur when UsersModule imports EmailsModule and EmailsModule imports UsersModule. NestJS throws a circular dependency error. Fix with forwardRef():

typescript
// users.module.ts @Module({ imports: [forwardRef(() => EmailsModule)], }) export class UsersModule {}

Dumping everything into AppModule is the antipattern I see most in junior codebases. 30-50 providers in the root module means you can't test anything in isolation and refactoring becomes painful. AppModule should import feature modules, not declare services directly.

Overusing global: true because "my service isn't injecting." It works, but it creates invisible coupling. Export from the declaring module and import where needed instead.

Real-world usage

  • nest new scaffolds AppModule importing feature modules from day one
  • @nestjs/passport wraps authentication as PassportModule with standard import syntax
  • Prisma integration lives in a shared PrismaModule exported to all features that need DB access
  • TypeOrmModule.forRoot() at the root and TypeOrmModule.forFeature([Entity]) in each feature module is the canonical dynamic module pattern
  • Microservice transport modules (TCP, Redis, Kafka) use the same @Module() structure, just with different controller decorators

Follow-up questions

Q: What is the difference between providers and exports?
A: providers registers classes in this module's DI scope. exports makes a subset of those providers available to other modules that import this one. A provider not in exports stays private.

Q: How does NestJS resolve injection tokens?
A: Via the class itself (most common) or a custom string/symbol token used with @Inject('TOKEN'). NestJS matches the token to a provider in the module graph and throws UnknownInjectionToken if it finds nothing.

Q: When should you use global: true on a module?
A: For app-wide singletons that every module genuinely needs, like config or logging. Avoid it for domain services - explicit imports make dependencies traceable.

Q: How do modules help with testing?
A: In unit tests, you create a TestingModule and replace real imports with mocks. The module boundary means you only need to provide the exact dependencies of the class you are testing, nothing else.

Q: Build a dynamic module that loads database config asynchronously using forRootAsync.
A: Use useFactory with async support inside the returned DynamicModule. The factory receives injected dependencies (like ConfigService) via inject and returns the config object. NestJS waits for the promise before completing module setup and accepting requests.

Examples

Feature module: user endpoints

typescript
// users/users.module.ts import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; @Module({ controllers: [UsersController], providers: [UsersService], exports: [UsersService], // AuthModule will need this to verify users }) export class UsersModule {} // users/users.service.ts import { Injectable } from '@nestjs/common'; @Injectable() export class UsersService { private users = [{ id: 1, name: 'Alice' }]; findById(id: number) { return this.users.find(u => u.id === id); } } // users/users.controller.ts import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} @Get(':id') getUser(@Param('id', ParseIntPipe) id: number) { return this.usersService.findById(id); // DI handles the wiring } }

UsersService is declared in UsersModule and injected into UsersController with no manual new UsersService(). The controller just lists the dependency in its constructor.

Shared module for database access

typescript
// database/database.module.ts import { Module } from '@nestjs/common'; import { DatabaseService } from './database.service'; @Module({ providers: [DatabaseService], exports: [DatabaseService], // any importing module gets access }) export class DatabaseModule {} // users/users.module.ts import { Module } from '@nestjs/common'; import { DatabaseModule } from '../database/database.module'; import { UsersService } from './users.service'; import { UsersController } from './users.controller'; @Module({ imports: [DatabaseModule], // pulls in DatabaseService controllers: [UsersController], providers: [UsersService], // UsersService can now inject DatabaseService }) export class UsersModule {} // users/users.service.ts @Injectable() export class UsersService { constructor(private readonly db: DatabaseService) {} findAll() { return this.db.query('SELECT * FROM users'); } }

DatabaseService is defined once and shared across UsersModule, OrdersModule, or any feature that imports DatabaseModule. One definition, multiple consumers.

Advanced: dynamic module with async factory

typescript
// config/config.module.ts import { DynamicModule, Module } from '@nestjs/common'; import { ConfigService } from './config.service'; @Module({}) export class ConfigModule { static forRootAsync(options: { useFactory: (...args: any[]) => Promise<Record<string, string>>; inject?: any[]; }): DynamicModule { return { module: ConfigModule, global: true, providers: [ { provide: 'CONFIG_OPTIONS', useFactory: options.useFactory, inject: options.inject || [], }, ConfigService, ], exports: [ConfigService], }; } } // app.module.ts @Module({ imports: [ ConfigModule.forRootAsync({ useFactory: async () => ({ DB_HOST: process.env.DB_HOST ?? 'localhost', JWT_SECRET: process.env.JWT_SECRET ?? 'dev-secret', }), }), ], }) export class AppModule {}

The factory runs async before the app accepts any requests. ConfigService is available everywhere because global: true. This is how @nestjs/config works internally.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?