NestJS Modules Explained

NestJS borrows heavily from Angular's architecture, and modules are its foundational building block. Every NestJS application has at least one module — the root module — and in practice you'll have many, each encapsulating a distinct domain of your application.
What Is a Module?
A module is a class annotated with the @Module() decorator. It tells NestJS how to assemble the application graph — which providers exist, which controllers belong here, and what this module exposes to the outside world.
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}The @Module() decorator takes a single metadata object with four optional properties:
| Property | Description |
|---|---|
providers | Services and other injectables that will be instantiated by the NestJS injector |
controllers | Controllers to be instantiated in this module |
imports | Other modules whose exported providers this module needs |
exports | Providers from this module that other modules can import |
The Root Module
Every app starts from a root module, passed to NestFactory.create():
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();The root module (AppModule) is just a regular module — by convention it lives in app.module.ts and imports all feature modules.
Feature Modules
As your application grows, you split domain logic into feature modules. A cats feature, for example, groups all cats-related code:
src/
cats/
dto/
create-cat.dto.ts
cats.controller.ts
cats.service.ts
cats.module.ts
app.module.ts
main.tsThe feature module declares its own controllers and providers:
// cats/cats.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class CatsModule {}Then the root module imports it:
// app.module.ts
import { Module } from '@nestjs/common';
import { CatsModule } from './cats/cats.module';
@Module({
imports: [CatsModule],
})
export class AppModule {}CatsService is now scoped to CatsModule. Nothing outside can inject it unless CatsModule explicitly exports it.
Shared Modules
In NestJS, modules are singletons by default. Once a module is instantiated, that same instance is shared across all modules that import it. This is what makes NestJS efficient and predictable.
To make a provider available to other modules, export it:
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService], // now other modules can use CatsService
})
export class CatsModule {}Any module that imports CatsModule will receive the same CatsService instance:
@Module({
imports: [CatsModule], // gets access to CatsService
providers: [DogsService],
controllers: [DogsController],
})
export class DogsModule {}// dogs.service.ts — can now inject CatsService
@Injectable()
export class DogsService {
constructor(private catsService: CatsService) {}
}Module Re-exporting
A module can re-export modules it imports. This is useful for creating "barrel" modules that aggregate related functionality:
@Module({
imports: [CommonModule],
exports: [CommonModule], // re-export so importers get CommonModule too
})
export class CoreModule {}Now any module that imports CoreModule automatically gets everything CommonModule exports, without importing it directly.
Global Modules
Sometimes you have utilities (database connection, config service, logger) that every module needs. Importing them everywhere is tedious. Mark them with @Global():
import { Global, Module } from '@nestjs/common';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';
@Global()
@Module({
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService],
})
export class CatsModule {}A global module only needs to be registered once (typically in the root module). After that, any module can inject its exported providers without importing the module.
Note: Don't overuse
@Global(). It bypasses the explicit dependency graph that makes NestJS apps maintainable. Use it sparingly — config, database, and logging are legitimate candidates.
Dynamic Modules
Static modules cover most cases, but sometimes you need to configure a module at runtime — think database connection strings or API keys passed via environment variables.
Dynamic modules let a module return its own metadata, computed at registration time:
import { Module, DynamicModule } from '@nestjs/common';
import { DatabaseService } from './database.service';
@Module({})
export class DatabaseModule {
static forRoot(uri: string): DynamicModule {
return {
module: DatabaseModule,
providers: [
{
provide: 'DATABASE_URI',
useValue: uri,
},
DatabaseService,
],
exports: [DatabaseService],
};
}
}Usage in AppModule:
@Module({
imports: [DatabaseModule.forRoot('mongodb://localhost/mydb')],
})
export class AppModule {}The convention is forRoot() for singleton configuration (used once in root) and forFeature() for module-level configuration (used in feature modules, like TypeORM's forFeature([UserEntity])).
Making Dynamic Modules Re-importable
To allow other modules to re-import a dynamic module with the same configuration, return global: true or expose a forRootAsync() variant. Many NestJS ecosystem packages (TypeORM, Config, JWT) follow this exact pattern.
static forRootAsync(options: DatabaseOptions): DynamicModule {
return {
module: DatabaseModule,
imports: options.imports || [],
providers: [
{
provide: 'DATABASE_OPTIONS',
useFactory: options.useFactory,
inject: options.inject || [],
},
DatabaseService,
],
exports: [DatabaseService],
};
}forRootAsync() defers provider creation to a factory function, which means you can inject ConfigService or other async values:
DatabaseModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
uri: config.get('DB_URI'),
}),
inject: [ConfigService],
}),Module Lifecycle Summary
Here's how modules behave at runtime:
Key points:
- NestJS builds a dependency graph at startup, instantiating modules and their providers in the right order
- Providers are singletons within their module scope
- Shared modules share the same instance across importers
- Global modules skip the import requirement entirely
Practical Example: User Feature Module
A real-world feature module for user management:
// users/users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])], // dynamic module for this feature
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // AuthModule will need this
})
export class UsersModule {}// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
UsersModule, // imports UsersService
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: '7d' },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController],
providers: [AuthService],
})
export class AuthModule {}// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // global config
TypeOrmModule.forRootAsync({ ... }), // global database
UsersModule,
AuthModule,
],
})
export class AppModule {}This structure keeps each domain self-contained: UsersModule owns all user logic, AuthModule composes it with JWT capability, and AppModule wires everything together.
Key Takeaways
✅ Every NestJS app has a root module that imports all feature modules
✅ Feature modules encapsulate controllers and providers for a single domain
✅ exports controls what a module shares with the outside world
✅ Modules are singletons — the same instance is shared across all importers
✅ @Global() makes exported providers available everywhere without explicit imports
✅ Dynamic modules (forRoot() / forFeature()) enable runtime configuration
✅ forRootAsync() supports async config via factory functions and injected services
Understanding modules is the key to understanding NestJS architecture. Everything — guards, interceptors, pipes, TypeORM repositories, JWT — plugs in through the module system.
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.