Core Patterns
NestJS modules are the unit of encapsulation. Each feature is a module; modules declare what they provide and what they export for others to consume. The patterns below cover the full lifecycle: basic feature modules, dynamic configuration, circular dependency resolution, and the global module escape hatch.
Feature Module Structure
A feature module groups the controller, service, and repository for one business capability. It exports only what other modules need — never more.
// src/users/user.entity.ts
export class User {
id: string;
name: string;
email: string;
role: 'user' | 'admin';
createdAt: Date;
}
// src/users/users.repository.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
@Injectable()
export class UsersRepository {
constructor(
@InjectRepository(User)
private readonly repo: Repository<User>
) {}
findById(id: string): Promise<User | null> {
return this.repo.findOneBy({ id });
}
findAll(): Promise<User[]> {
return this.repo.find();
}
save(user: Partial<User>): Promise<User> {
return this.repo.save(user);
}
}
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { CreateUserDto } from './dto/create-user.dto';
import { User } from './user.entity';
@Injectable()
export class UsersService {
constructor(private readonly users: UsersRepository) {}
async findById(id: string): Promise<User> {
const user = await this.users.findById(id);
if (!user) throw new NotFoundException(`User ${id} not found`);
return user;
}
create(dto: CreateUserDto): Promise<User> {
return this.users.save(dto);
}
}
// src/users/users.controller.ts
import { Controller, Get, Post, Body, Param, ParseUUIDPipe } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() { return this.usersService.findAll(); }
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.usersService.findById(id);
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}
// src/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 { UsersRepository } from './users.repository';
import { User } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [UsersService, UsersRepository],
exports: [UsersService], // export service, NOT repository — hide persistence details
})
export class UsersModule {}
Module export rule: export only what other modules legitimately need to call. UsersRepository is an implementation detail of UsersModule — never export it.
Dynamic Modules with forRoot() and forRootAsync()
Use dynamic modules to configure a shared module once at the application root. forRoot() takes a static config object; forRootAsync() accepts async factory functions and supports DI.
// src/shared/email/email.module.ts
import { Module, DynamicModule } from '@nestjs/common';
import { EmailService } from './email.service';
export interface EmailModuleOptions {
host: string;
port: number;
from: string;
}
// Injection token for the options object
export const EMAIL_OPTIONS = 'EMAIL_OPTIONS';
@Module({})
export class EmailModule {
// Static config — use when options are known at startup
static forRoot(options: EmailModuleOptions): DynamicModule {
return {
module: EmailModule,
providers: [
{ provide: EMAIL_OPTIONS, useValue: options },
EmailService,
],
exports: [EmailService],
global: false,
};
}
// Async config — use when options come from ConfigService or async source
static forRootAsync(asyncOptions: {
useFactory: (...args: any[]) => Promise<EmailModuleOptions> | EmailModuleOptions;
inject?: any[];
imports?: any[];
}): DynamicModule {
return {
module: EmailModule,
imports: asyncOptions.imports ?? [],
providers: [
{
provide: EMAIL_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject ?? [],
},
EmailService,
],
exports: [EmailService],
};
}
}
// src/email/email.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { EMAIL_OPTIONS, EmailModuleOptions } from './email.module';
@Injectable()
export class EmailService {
constructor(
@Inject(EMAIL_OPTIONS) private readonly options: EmailModuleOptions
) {}
async send(to: string, subject: string, body: string): Promise<void> {
// Use this.options.host, this.options.port, this.options.from
}
}
// src/app.module.ts — two ways to register
import { ConfigModule, ConfigService } from '@nestjs/config';
import { EmailModule } from './shared/email/email.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
// Static registration (simple):
// EmailModule.forRoot({ host: 'smtp.example.com', port: 587, from: 'no-reply@example.com' }),
// Async registration (reads from ConfigService):
EmailModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
host: config.get<string>('SMTP_HOST')!,
port: config.get<number>('SMTP_PORT')!,
from: config.get<string>('SMTP_FROM')!,
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}
Circular Dependency Resolution with forwardRef()
Circular dependencies signal that two modules are too tightly coupled. Before reaching for forwardRef(), try extracting the shared concept into a third module. When a circular dependency is unavoidable, use forwardRef() on both sides.
// WRONG: AuthModule imports UsersModule, UsersModule imports AuthModule
// → NestJS throws: "A circular dependency has been detected"
// BETTER FIRST: extract the shared concept
// UsersModule and AuthModule both import CredentialsModule
// — eliminates the cycle entirely
When extraction is not feasible:
// src/auth/auth.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
@Module({
imports: [forwardRef(() => UsersModule)], // lazy reference
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
// src/users/users.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [forwardRef(() => AuthModule)], // lazy reference on both sides
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
// src/auth/auth.service.ts — inject with forwardRef() in the service too
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService
) {}
}
Global Modules vs Feature Modules
Use @Global() sparingly. A global module is registered once and its exports are available everywhere without explicit import. Overusing it defeats the purpose of modular architecture.
// src/shared/logger/logger.module.ts
import { Global, Module } from '@nestjs/common';
import { LoggerService } from './logger.service';
// JUSTIFIED use of @Global():
// - LoggerService would need to be imported in every single feature module
// - It has no domain-specific configuration
// - It is pure infrastructure with no feature coupling
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
// With @Global(), any module can inject LoggerService without importing LoggerModule:
@Injectable()
export class UsersService {
constructor(private readonly logger: LoggerService) {} // works without importing LoggerModule
}
When NOT to use @Global():
Module Use @Global()? Reason
-------------------------- --------------- ------------------------------------------
LoggerModule YES Used by every module, pure infrastructure
ConfigModule YES (built-in) NestJS sets isGlobal: true itself
DatabaseModule YES DB connection is always shared
AuthModule NO Import explicitly — guards clarify what is protected
UsersModule NO Feature module — explicit imports show coupling
EmailModule NO Not every module sends email — import where needed
Registration order in AppModule:
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }), // global config first
LoggerModule, // global logger second
DatabaseModule.forRootAsync({ ... }), // global DB third
UsersModule, // feature modules after infrastructure
OrdersModule,
PaymentsModule,
],
})
export class AppModule {}