Back to blog

NestJS Modules Explained

nestjsnodejstypescriptbackend
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:

PropertyDescription
providersServices and other injectables that will be instantiated by the NestJS injector
controllersControllers to be instantiated in this module
importsOther modules whose exported providers this module needs
exportsProviders 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.ts

The 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.