If you've worked with NestJS for more than a day, you've seen this error:
Error: Nest can't resolve dependencies of the UserService (?).
Please make sure that the argument EmailService at index [1] is available in the UserModule context.
You scratch your head. You added EmailService to the providers array. Why isn't it working? So you start randomly importing modules, adding services everywhere until the error goes away. Now your app works, but you have no idea why, and your modules are a tangled mess.
This is one of the most common pain points for NestJS developers — both beginners and experienced ones. The module system is powerful but unforgiving. One missing export and your entire application crashes with cryptic error messages.
Let's fix this properly.
Table of Contents
- The Root Problem: Module Context
- Problem #1: Forgetting to Export
- Problem #2: Deep Dependency Chains
- Problem #3: Circular Dependencies Hell
- Problem #4: Repository Not Found
- Problem #5: Custom Provider Token Mismatches
- Problem #6: Global Modules Done Wrong
- Problem #7: Dynamic Modules and forRoot/forFeature
- Problem #8: Async Providers and Race Conditions
- The Debug Checklist
- The Mental Model
- Real-World Module Architecture
- Common Anti-Patterns to Avoid
- The Testing Connection
- Key Takeaways
The Root Problem: Module Context
NestJS doesn't have a global service registry like Angular. Each module has its own dependency injection context. A service is only available where it's provided and exported.
This design is intentional—it enforces modularity and prevents the "god object" anti-pattern where everything knows about everything. But it also means you need to be explicit about dependencies.
Think of it this way: NestJS treats each module as a black box. Unless you explicitly open that box (exports), nobody else can see what's inside.
Problem #1: Forgetting to Export
The Classic Mistake
This is the #1 cause of DI errors in NestJS:
// email.module.ts
@Module({
providers: [EmailService], // ❌ Provided but NOT exported
})
export class EmailModule {}
// user.module.ts
@Module({
imports: [EmailModule],
providers: [UserService], // UserService needs EmailService
controllers: [UserController],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly emailService: EmailService // ❌ ERROR!
) {}
}
Error:
Nest can't resolve dependencies of the UserService (?). Please make sure that the argument EmailService at index [0] is available in the UserModule context.
Why it fails:
- You imported
EmailModuleintoUserModule -
EmailModuleprovidesEmailService - But
EmailModuledoesn't exportEmailService - Therefore,
UserModulecan't see it — even though the module is imported!
The Correct Fix
// email.module.ts
@Module({
providers: [EmailService],
exports: [EmailService], // ✅ Export it!
})
export class EmailModule {}
// user.module.ts
@Module({
imports: [EmailModule], // ✅ Now UserModule can access EmailService
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
Rule #1: If another module needs it, EXPORT IT.
When NOT to Export
You don't need to export services that are:
- Only used internally within the module
- Only injected into controllers in the same module
- Helper services not meant for external consumption
// email.module.ts
@Module({
providers: [
EmailService, // ✅ Exported (public API)
EmailTemplateEngine, // ✅ NOT exported (internal helper)
EmailValidator, // ✅ NOT exported (internal helper)
],
exports: [EmailService], // Only export the public interface
})
export class EmailModule {}
This encapsulation is good—it hides implementation details.
Problem #2: Deep Dependency Chains
This is where it gets messy. Your service depends on a service that depends on another service. New developers often import everything up the chain, creating unnecessary coupling.
The Scenario
// logger.service.ts
@Injectable()
export class LoggerService {
constructor(
@Inject("CONFIG") private config: AppConfig // Needs CONFIG token
) {}
log(message: string) {
if (this.config.debug) {
console.log(`[${new Date().toISOString()}] ${message}`);
}
}
}
// email.service.ts
@Injectable()
export class EmailService {
constructor(
private readonly logger: LoggerService // Needs LoggerService
) {}
async sendEmail(to: string, subject: string, body: string) {
this.logger.log(`Sending email to ${to}`);
// Send email...
}
}
// user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly emailService: EmailService // Needs EmailService
) {}
async createUser(email: string) {
// Create user...
await this.emailService.sendEmail(
email,
"Welcome!",
"Thanks for signing up"
);
}
}
Bad Solution (What Most People Do)
// user.module.ts
@Module({
imports: [
EmailModule,
LoggerModule, // ❌ Not needed if EmailModule handles it!
ConfigModule, // ❌ Definitely not needed here!
],
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
Why it's bad:
-
UserModulenow imports things it doesn't directly use - If
EmailServicechanges its dependencies tomorrow (addsQueueService), you'll need to updateUserModuletoo - Creates tight coupling across modules—changing one affects many
- Makes refactoring a nightmare
- Harder to understand what
UserServiceactually needs - Unit testing becomes complicated (too many dependencies to mock)
Correct Solution
// config.module.ts
@Module({
providers: [
{
provide: "CONFIG",
useValue: {
debug: true,
emailApiKey: process.env.EMAIL_API_KEY,
},
},
],
exports: ["CONFIG"], // ✅ Export for others
})
export class ConfigModule {}
// logger.module.ts
@Module({
imports: [ConfigModule], // LoggerService needs CONFIG
providers: [LoggerService],
exports: [LoggerService], // ✅ Export LoggerService
})
export class LoggerModule {}
// email.module.ts
@Module({
imports: [LoggerModule], // EmailService needs LoggerService
providers: [EmailService],
exports: [EmailService], // ✅ Export EmailService
})
export class EmailModule {}
// user.module.ts
@Module({
imports: [EmailModule], // ✅ Only import what YOU directly use
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
Why it works:
- Each module only imports what its services directly inject
-
UserModuledoesn't care aboutLoggerModuleorConfigModule -
EmailModulehandles its own dependencies - Changes to
LoggerServicedependencies don't affectUserModule - Each module is self-contained and independently testable
Rule #2: Only import modules for services YOU directly inject, not their transitive dependencies.
Example: Payment Processing
// payment-gateway.service.ts
@Injectable()
export class PaymentGatewayService {
constructor(
private readonly http: HttpService,
private readonly config: ConfigService,
private readonly logger: LoggerService
) {}
}
// payment.service.ts
@Injectable()
export class PaymentService {
constructor(
private readonly gateway: PaymentGatewayService,
private readonly db: DatabaseService
) {}
}
// order.service.ts
@Injectable()
export class OrderService {
constructor(
private readonly payment: PaymentService // Only needs PaymentService
) {}
}
// order.module.ts
@Module({
imports: [
PaymentModule, // ✅ ONLY this
// NOT: HttpModule, ConfigModule, LoggerModule, DatabaseModule
],
providers: [OrderService],
})
export class OrderModule {}
Problem #3: Circular Dependencies Hell
Circular dependencies are NestJS's way of telling you: "Your architecture needs work."
The Scenario
Error:
A circular dependency has been detected between UserModule and OrderModule. Please, make sure that each side of a bidirectional relationships are using "forwardRef()".
The Bad Pattern
// user.module.ts
@Module({
imports: [OrderModule], // UserService needs OrderService
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
// order.module.ts
@Module({
imports: [UserModule], // OrderService needs UserService
providers: [OrderService],
exports: [OrderService],
})
export class OrderModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(private readonly orderService: OrderService) {}
getUserWithOrders(userId: string) {
return this.orderService.getOrdersByUser(userId);
}
}
// order.service.ts
@Injectable()
export class OrderService {
constructor(private readonly userService: UserService) {}
getOrderWithUser(orderId: string) {
return this.userService.getUserById(userId);
}
}
Band-Aid Fix (Don't Do This)
// user.module.ts
@Module({
imports: [forwardRef(() => OrderModule)], // 🚫 Hack!
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
// order.module.ts
@Module({
imports: [forwardRef(() => UserModule)], // 🚫 Another hack!
providers: [OrderService],
exports: [OrderService],
})
export class OrderModule {}
Why forwardRef is terrible:
- It's a band-aid on a design flaw
- Still creates tight coupling
- Initialization order becomes unpredictable
- Makes the codebase harder to reason about
- Testing becomes complicated (which mock loads first?)
- Can cause subtle runtime bugs
- Indicates poor separation of concerns
Proper Fix #1: Introduce a Shared Module
Extract the shared logic into a separate service:
// user-order.module.ts
@Module({
providers: [UserOrderService], // Handles cross-cutting logic
exports: [UserOrderService],
})
export class UserOrderModule {}
// user-order.service.ts
@Injectable()
export class UserOrderService {
// This service can coordinate between users and orders
// without creating a circular dependency
async getUserWithOrders(userId: string) {
// Fetch user and orders together
}
async getOrderWithUser(orderId: string) {
// Fetch order and user together
}
}
// user.module.ts
@Module({
imports: [UserOrderModule], // ✅ No circle
providers: [UserService],
exports: [UserService],
})
export class UserModule {}
// order.module.ts
@Module({
imports: [UserOrderModule], // ✅ No circle
providers: [OrderService],
exports: [OrderService],
})
export class OrderModule {}
Proper Fix #2: Use Events (Decoupling)
For truly independent modules, use an event-driven approach:
// user.service.ts
@Injectable()
export class UserService {
constructor(private readonly eventEmitter: EventEmitter2) {}
async createUser(data: CreateUserDto) {
const user = await this.userRepository.save(data);
// Emit event instead of calling OrderService directly
this.eventEmitter.emit("user.created", { userId: user.id });
return user;
}
}
// order.service.ts
@Injectable()
export class OrderService {
@OnEvent("user.created")
async handleUserCreated(payload: { userId: string }) {
// Create welcome order, send email, etc.
await this.createWelcomeOrder(payload.userId);
}
}
Now UserModule and OrderModule don't know about each other at all!
Proper Fix #3: Dependency Inversion
Sometimes the real issue is that your dependencies point the wrong way:
// BEFORE (Bad): OrderService depends on UserService
class OrderService {
constructor(private userService: UserService) {}
}
// AFTER (Good): Both depend on abstractions
// user.interface.ts
export interface IUserRepository {
findById(id: string): Promise<User>;
}
// order.service.ts
class OrderService {
constructor(
@Inject("IUserRepository")
private userRepo: IUserRepository
) {}
}
Rule #3: Circular dependencies mean bad design. Refactor, don't hack with forwardRef.
Problem #4: Repository Not Found
This trips up every developer using TypeORM, Mongoose, or Prisma with NestJS.
The Mistake
// user.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User])], // Registers repository
providers: [UserService],
controllers: [UserController],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly userRepository: UserRepository // ❌ Wrong!
) {}
}
Error:
Nest can't resolve dependencies of the UserService (?). Please make sure that the argument UserRepository at index [0] is available in the UserModule context.
Why it fails:
- TypeORM doesn't register a provider called UserRepository
- It registers a provider with a special token
- You need to use
@InjectRepository()to access that token
The Fix
// user.service.ts
@Injectable()
export class UserService {
constructor(
@InjectRepository(User) // ✅ Use the decorator
private readonly userRepository: Repository<User>
) {}
async findById(id: string): Promise<User> {
return this.userRepository.findOne({ where: { id } });
}
}
Same Issue with Custom Repositories
// user.repository.ts
@EntityRepository(User)
export class UserRepository extends Repository<User> {
async findByEmail(email: string): Promise<User> {
return this.findOne({ where: { email } });
}
}
// user.module.ts
@Module({
imports: [TypeOrmModule.forFeature([User, UserRepository])],
providers: [UserService],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
@InjectRepository(UserRepository) // ✅ Inject custom repository
private readonly userRepository: UserRepository
) {}
}
Mongoose Example
// user.service.ts
@Injectable()
export class UserService {
constructor(
@InjectModel(User.name) // ✅ Mongoose version
private readonly userModel: Model<User>
) {}
}
Rule #4: ORM repositories need special decorators—@InjectRepository() for TypeORM, @InjectModel() for Mongoose.
Problem #5: Custom Provider Token Mismatches
String tokens are error-prone. One typo and your app crashes.
The Problem
// auth.module.ts
@Module({
providers: [
{
provide: "JWT_SERVICE", // Token is a string
useClass: JwtService,
},
],
exports: ["JWT_SERVICE"],
})
export class AuthModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
@Inject("JwtService") // ❌ Typo! Should be 'JWT_SERVICE'
private readonly jwt: JwtService
) {}
}
Error:
Nest can't resolve dependencies of the UserService (?). Please make sure that the argument dependency at index [0] is available.
The issue: The error doesn't tell you about the typo. You'll waste time checking imports and exports when the real problem is a string mismatch.
Better Approach: Use Symbol Constants
// constants/tokens.ts
export const JWT_SERVICE = Symbol("JWT_SERVICE");
export const DATABASE_CONNECTION = Symbol("DATABASE_CONNECTION");
export const CACHE_MANAGER = Symbol("CACHE_MANAGER");
// auth.module.ts
import { JWT_SERVICE } from "./constants/tokens";
@Module({
providers: [
{
provide: JWT_SERVICE, // ✅ No quotes, TypeScript-checked
useClass: JwtService,
},
],
exports: [JWT_SERVICE],
})
export class AuthModule {}
// user.service.ts
import { JWT_SERVICE } from "./constants/tokens";
@Injectable()
export class UserService {
constructor(
@Inject(JWT_SERVICE) // ✅ TypeScript will catch typos
private readonly jwt: JwtService
) {}
}
Benefits:
- TypeScript autocomplete works
- Refactoring tools can rename across files
- No runtime typos possible
- Better IDE support
- Symbols are guaranteed unique (even if two have the same description)
InjectionToken Pattern (Angular-style)
For even more type safety:
// tokens.ts
import { InjectionToken } from "@nestjs/common";
export interface AppConfig {
apiKey: string;
debug: boolean;
}
export const APP_CONFIG = new InjectionToken<AppConfig>("APP_CONFIG");
// config.module.ts
@Module({
providers: [
{
provide: APP_CONFIG,
useValue: {
apiKey: process.env.API_KEY,
debug: process.env.NODE_ENV === "development",
},
},
],
exports: [APP_CONFIG],
})
export class ConfigModule {}
// service.ts
@Injectable()
export class MyService {
constructor(@Inject(APP_CONFIG) private readonly config: AppConfig) {
// config is fully typed!
}
}
Rule #5: Use Symbol constants or InjectionToken for provider tokens to avoid typos and gain type safety.
Problem #6: Global Modules Done Wrong
Global modules are powerful but dangerous. Use them wisely.
The Shotgun Approach (Bad)
Every module imports the same infrastructure modules:
// feature-a.module.ts
@Module({
imports: [
ConfigModule, // ❌ Repeated
LoggerModule, // ❌ Repeated
DatabaseModule, // ❌ Repeated
CacheModule, // ❌ Repeated
// Actual feature imports...
],
})
export class FeatureAModule {}
// feature-b.module.ts
@Module({
imports: [
ConfigModule, // ❌ Repeated
LoggerModule, // ❌ Repeated
DatabaseModule, // ❌ Repeated
CacheModule, // ❌ Repeated
// Actual feature imports...
],
})
export class FeatureBModule {}
Problems:
- Violates DRY principle
- Easy to forget one and get cryptic errors
- Clutters every module's imports array
- Makes dependencies less explicit
Proper Global Module
// logger.module.ts
@Global() // ✅ Makes it available everywhere
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
// config.module.ts
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
// database.module.ts
@Global()
@Module({
providers: [DatabaseService],
exports: [DatabaseService],
})
export class DatabaseModule {}
// app.module.ts
@Module({
imports: [
// ✅ Import global modules once in root
LoggerModule,
ConfigModule,
DatabaseModule,
// Feature modules don't need to import global modules
UserModule,
OrderModule,
PaymentModule,
],
})
export class AppModule {}
// user.module.ts
@Module({
imports: [
// ✅ No ConfigModule, LoggerModule needed!
EmailModule, // Only feature-specific imports
],
providers: [UserService],
})
export class UserModule {}
// user.service.ts
@Injectable()
export class UserService {
constructor(
private readonly logger: LoggerService, // ✅ Just works!
private readonly config: ConfigService // ✅ Just works!
) {}
}
When to Use @Global()
Good candidates for global modules:
- Configuration - Needed everywhere
- Logging - Every service logs
- Database connections - Core infrastructure
- Caching - Cross-cutting concern
- Authentication guards - Application-wide
Bad candidates (don't make global):
- Feature modules -
UserModule,OrderModule, etc. - Business logic services -
PaymentService,EmailService - Third-party integrations -
StripeModule,TwilioModule
Rule #6: Use @Global() for truly universal infrastructure services (config, logging, database), but don't abuse it.
The Hybrid Approach
Sometimes you want a module to be available globally but still explicitly imported for clarity:
// logger.module.ts
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
// user.module.ts
@Module({
imports: [LoggerModule], // ✅ Optional, but documents dependencies clearly
providers: [UserService],
})
export class UserModule {}
This is a matter of team preference and coding standards.
Problem #7: Dynamic Modules and forRoot/forFeature
Dynamic modules (like TypeOrmModule.forRoot()) confuse many developers.
Understanding forRoot vs forFeature
// app.module.ts
@Module({
imports: [
TypeOrmModule.forRoot({
// ✅ Configure once in root module
type: "postgres",
host: "localhost",
port: 5432,
// ...
}),
UserModule,
OrderModule,
],
})
export class AppModule {}
// user.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([User]), // ✅ Register entities per module
],
providers: [UserService],
})
export class UserModule {}
Key distinction:
-
forRoot()- Global configuration, called once in AppModule -
forFeature()- Feature-specific registration, called in each feature module
Common Mistake: Calling forRoot Multiple Times
// ❌ WRONG
@Module({
imports: [
TypeOrmModule.forRoot({
/* config */
}), // Called in AppModule
],
})
export class AppModule {}
@Module({
imports: [
TypeOrmModule.forRoot({
/* config */
}), // ❌ Called again here!
],
})
export class UserModule {}
This creates multiple database connections and causes weird errors.
Creating Your Own Dynamic Module
// cache.module.ts
@Module({})
export class CacheModule {
static forRoot(options: CacheOptions): DynamicModule {
return {
module: CacheModule,
global: options.isGlobal ?? false,
providers: [
{
provide: "CACHE_OPTIONS",
useValue: options,
},
CacheService,
],
exports: [CacheService],
};
}
}
// app.module.ts
@Module({
imports: [
CacheModule.forRoot({
ttl: 3600,
isGlobal: true,
}),
],
})
export class AppModule {}
Problem #8: Async Providers and Race Conditions
Sometimes providers need async initialization (database connections, config from remote sources, etc.).
The Problem
// database.module.ts
@Module({
providers: [
{
provide: "DATABASE_CONNECTION",
useFactory: async () => {
return await createConnection(); // Async!
},
},
UserService, // ❌ Might try to use connection before it's ready
],
})
export class DatabaseModule {}
The Solution: useFactory with inject
// database.module.ts
@Module({
providers: [
{
provide: "DATABASE_CONNECTION",
useFactory: async (config: ConfigService) => {
const connection = await createConnection({
host: config.get("DB_HOST"),
port: config.get("DB_PORT"),
});
return connection;
},
inject: [ConfigService], // ✅ Dependencies
},
],
exports: ["DATABASE_CONNECTION"],
})
export class DatabaseModule {}
NestJS waits for async factories to resolve before injecting them elsewhere.
Real Example: JWT Module
// auth.module.ts
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
secret: config.get("JWT_SECRET"),
signOptions: { expiresIn: "1h" },
}),
inject: [ConfigService],
}),
],
providers: [AuthService],
})
export class AuthModule {}
The Debug Checklist
When you see "can't resolve dependencies," follow this systematic approach:
Step 1: Read the Error Carefully
Error: Nest can't resolve dependencies of the UserService (?).
Please make sure that the argument EmailService at index [1] is available in the UserModule context.
Key information:
- Service with problem: UserService
- Missing dependency: EmailService
- Position: index 1
- Context: UserModule
Step 2: Check the Constructor
@Injectable()
export class UserService {
constructor(
private readonly userRepo: Repository<User>, // index [0]
private readonly emailService: EmailService // index [1] ← Problem here
) {}
}
Step 3: Follow the Checklist
-
Is the service provided?
- Check EmailModule has EmailService in providers array
-
Is it exported?
- Check EmailModule has EmailService in exports array
-
Is the module imported?
- Check UserModule imports EmailModule
-
Custom token?
- If using
@Inject('TOKEN'), verify the token matches exactly
- If using
-
Repository?
- If it's a TypeORM/Mongoose model, use
@InjectRepository()or@InjectModel()
- If it's a TypeORM/Mongoose model, use
-
Circular dependency?
- Check if two modules import each other
-
Is it a class at all?
- TypeScript interfaces don't exist at runtime—can't inject them
Step 4: Enable Debug Logging
// main.ts
const app = await NestFactory.create(AppModule, {
logger: ["error", "warn", "log", "debug", "verbose"], // ✅ Full logging
});
This shows the module initialization order and can reveal timing issues.
The Mental Model
The best way to understand NestJS modules is with a simple metaphor.
Modules as Boxes
Think of modules as sealed boxes:
┌─────────────────────────────┐
│ EmailModule (Box) │
├─────────────────────────────┤
│ providers: │
│ - EmailService ───────┼──> ❌ Can't see from outside
│ - EmailTemplate ───────┼──> ❌ Can't see from outside
│ │
│ exports: │
│ - EmailService ───────┼──> ✅ Visible to importers
└─────────────────────────────┘
-
providers= "What's inside this box" -
exports= "What I'm willing to share from this box" -
imports= "Other boxes I need stuff from"
The Three Laws of NestJS DI
Law #1: A service is only accessible if:
- It's provided in a module
- That module exports it
- Your module imports that module
Law #2: Modules are NOT transitive
- If A imports B, and B imports C
- A does NOT automatically have access to C's providers
- A must import C directly if it needs C's services
Law #3: Global modules break the rules (by design)
-
@Global()modules are accessible everywhere - But this should be rare—only for infrastructure
Real-World Module Architecture
Let's look at a complete, production-ready structure.
Bad Architecture (Monolith)
AppModule
├── imports: [TypeOrmModule, ConfigModule, ...]
├── providers: [
│ UserService,
│ OrderService,
│ PaymentService,
│ EmailService,
│ SmsService,
│ LoggerService,
│ ... 50 more services
│ ]
├── controllers: [
│ UserController,
│ OrderController,
│ PaymentController,
│ ... 30 more controllers
│ ]
└── exports: []
Problems with this approach:
- Everything is in one giant module
- No clear boundaries between features
- Can't lazy load anything
- Testing requires the entire app context
- Team conflicts in the same files
- Impossible to scale the team
- No code ownership boundaries
Good Architecture (Feature Modules)
AppModule
├── CoreModule (Global infrastructure)
│ ├── ConfigModule
│ ├── LoggerModule
│ └── DatabaseModule
│
├── SharedModule (Reusable utilities)
│ ├── EmailModule
│ ├── SmsModule
│ └── StorageModule
│
├── Feature Modules (Business domains)
│ ├── UserModule
│ ├── OrderModule
│ └── PaymentModule
│
└── IntegrationModule (Third-party services)
├── StripeModule
├── SendGridModule
└── AwsModule
Complete Example
// ===== CORE LAYER (Global) =====
// config.module.ts
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
// logger.module.ts
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}
// database.module.ts
@Global()
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
type: "postgres",
host: config.get("DB_HOST"),
port: config.get("DB_PORT"),
database: config.get("DB_NAME"),
autoLoadEntities: true,
}),
inject: [ConfigService],
}),
],
})
export class DatabaseModule {}
// core.module.ts
@Module({
imports: [ConfigModule, LoggerModule, DatabaseModule],
exports: [ConfigModule, LoggerModule], // Re-export for convenience
})
export class CoreModule {}
// ===== SHARED LAYER (Reusable services) =====
// email.module.ts
@Module({
imports: [CoreModule], // Has access to Logger, Config
providers: [EmailService, EmailTemplateEngine],
exports: [EmailService], // Only export public API
})
export class EmailModule {}
// sms.module.ts
@Module({
imports: [CoreModule],
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}
// shared.module.ts
@Module({
imports: [EmailModule, SmsModule],
exports: [EmailModule, SmsModule], // Re-export shared services
})
export class SharedModule {}
// ===== FEATURE LAYER (Business logic) =====
// user.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([User]), // User entity
SharedModule, // EmailService, SmsService
],
providers: [UserService, UserRepository],
controllers: [UserController],
exports: [UserService], // Other features can use UserService
})
export class UserModule {}
// order.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([Order]),
UserModule, // ✅ Needs UserService
PaymentModule, // ✅ Needs PaymentService
],
providers: [OrderService],
controllers: [OrderController],
exports: [OrderService],
})
export class OrderModule {}
// payment.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([Payment]),
StripeModule, // Third-party integration
],
providers: [PaymentService, PaymentGateway],
controllers: [PaymentController],
exports: [PaymentService],
})
export class PaymentModule {}
// ===== INTEGRATION LAYER (External APIs) =====
// stripe.module.ts
@Module({
imports: [CoreModule],
providers: [
{
provide: "STRIPE_CLIENT",
useFactory: (config: ConfigService) => {
return new Stripe(config.get("STRIPE_SECRET_KEY"), {
apiVersion: "2023-10-16",
});
},
inject: [ConfigService],
},
StripeService,
],
exports: [StripeService],
})
export class StripeModule {}
// ===== ROOT MODULE =====
// app.module.ts
@Module({
imports: [
// Core (imported once, available globally)
CoreModule,
// Shared utilities
SharedModule,
// Feature modules
UserModule,
OrderModule,
PaymentModule,
// Integrations
StripeModule,
],
})
export class AppModule {}
Benefits of This Structure:
- Clear separation of concerns - Each layer has a distinct purpose
- Explicit dependencies - Easy to see what depends on what
- Reusability - SharedModule services used across features
- Testability - Mock only what you need
- Scalability - Teams can own specific modules
- Maintainability - Changes are localized
Common Anti-Patterns to Avoid
Anti-Pattern #1: The God Module
// ❌ DON'T: One module that does everything
@Module({
imports: [
/* 20 modules */
],
providers: [
/* 50 services */
],
controllers: [
/* 30 controllers */
],
})
export class AppModule {}
Fix: Split into feature modules based on business domains.
Anti-Pattern #2: Import Hell
// ❌ DON'T: Import everything just in case
@Module({
imports: [
UserModule,
OrderModule,
PaymentModule,
EmailModule,
SmsModule,
LoggerModule,
ConfigModule,
DatabaseModule,
CacheModule,
QueueModule,
// ... 20 more modules "just in case"
],
providers: [SimpleService], // Only uses EmailService
})
export class SimpleModule {}
Fix: Only import what you directly inject.
Anti-Pattern #3: Re-providing Services
// ❌ DON'T: Provide the same service in multiple modules
@Module({
providers: [EmailService], // Provided here
exports: [EmailService],
})
export class EmailModule {}
@Module({
imports: [EmailModule],
providers: [EmailService], // ❌ Provided again!
exports: [EmailService],
})
export class UserModule {}
Fix: Only provide once, import and re-export if needed.
Anti-Pattern #4: Barrel Export Abuse
// ❌ DON'T: Export everything from index.ts
// user/index.ts
export * from "./user.service";
export * from "./user.controller";
export * from "./user.repository";
export * from "./user.entity";
export * from "./dto";
export * from "./interfaces";
// Then in another module:
import { UserService } from "../user"; // Which User? What's available?
Fix: Be explicit about what's public API:
// user/index.ts
export { UserModule } from "./user.module";
export { UserService } from "./user.service"; // Only if meant to be imported
export { CreateUserDto } from "./dto/create-user.dto"; // Public DTOs only
// Internal details stay private
Anti-Pattern #5: Ignoring Module Boundaries
// ❌ DON'T: Access internals directly
// order.service.ts
import { UserRepository } from "../user/user.repository"; // ❌ Reaching into internals
@Injectable()
export class OrderService {
constructor(
private readonly userRepo: UserRepository // ❌ Skip the service layer
) {}
}
Fix: Always go through the public service API:
// ✅ DO: Use the exported service
import { UserService } from "../user";
@Injectable()
export class OrderService {
constructor(
private readonly userService: UserService // ✅ Use public API
) {}
}
Anti-Pattern #6: forwardRef Everywhere
// ❌ DON'T: forwardRef as your go-to solution
@Module({
imports: [
forwardRef(() => ModuleA),
forwardRef(() => ModuleB),
forwardRef(() => ModuleC),
],
})
export class MyModule {}
Fix: Redesign your architecture (see Problem #3).
The Testing Connection
Proper DI makes testing dramatically easier. Here's why module structure matters for tests.
Testing with Good DI
// user.service.spec.ts
describe("UserService", () => {
let service: UserService;
let emailService: EmailService;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
UserService,
{
provide: EmailService,
useValue: {
sendEmail: jest.fn(), // ✅ Easy to mock
},
},
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
save: jest.fn(),
},
},
],
}).compile();
service = module.get<UserService>(UserService);
emailService = module.get<EmailService>(EmailService);
});
it("should send email when creating user", async () => {
await service.createUser({ email: "test@example.com" });
expect(emailService.sendEmail).toHaveBeenCalledWith(
"test@example.com",
expect.any(String),
expect.any(String)
);
});
});
Testing with Bad DI (Tight Coupling)
// ❌ If your service has hidden dependencies...
class UserService {
constructor() {
this.logger = new LoggerService(); // ❌ Hard-coded
this.email = new EmailService(); // ❌ Can't mock
}
}
// Your test is now much harder:
describe("UserService", () => {
it("tests something", () => {
const service = new UserService(); // ❌ Can't control dependencies
// Now you're stuck with real EmailService, real Logger...
});
});
Integration Tests with Modules
// user.module.integration.spec.ts
describe("UserModule (Integration)", () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [
UserModule,
TypeOrmModule.forRoot({
type: "sqlite",
database: ":memory:",
entities: [User],
synchronize: true,
}),
],
})
.overrideProvider(EmailService)
.useValue({ sendEmail: jest.fn() }) // Mock external services
.compile();
app = module.createNestApplication();
await app.init();
});
it("should create user end-to-end", async () => {
return request(app.getHttpServer())
.post("/users")
.send({ email: "test@example.com" })
.expect(201);
});
afterAll(async () => {
await app.close();
});
});
Testing Best Practices:
- Unit tests - Mock all dependencies, test service logic in isolation
- Integration tests - Import real modules, mock only external APIs
- E2E tests - Real modules, real database (test DB), mock third-party APIs
Key Takeaways
Let's wrap this up with the essential rules you need to remember:
The Five Core Rules:
- Export what others need - If another module injects it, export it
- Import only direct dependencies - Not transitive ones
- Circular dependencies = bad design - Refactor, don't hack
-
ORM repositories need special decorators -
@InjectRepository(),@InjectModel() - Use constants for tokens - Symbols or InjectionToken, not strings
The Mental Model:
- Modules are black boxes
- Only exported providers are visible
- Imports are not transitive
- Global modules are the exception
The Debug Process:
- Read the error carefully (which service, which dependency, which module)
- Check: provided? exported? imported?
- Check for custom tokens, repositories, circular dependencies
- Enable verbose logging if stuck
The Architecture Principles:
- Core layer - Global infrastructure (config, logging, database)
- Shared layer - Reusable utilities (email, SMS, storage)
- Feature layer - Business domains (user, order, payment)
- Integration layer - External APIs (Stripe, SendGrid, AWS)
When You're Stuck:
Ask yourself these questions:
- Is this service provided in a module?
- Is that module exporting the service?
- Is my module importing that module?
- Am I using the right injection decorator?
- Could this be a circular dependency?
- Am I confusing
forRoot()andforFeature()?
The Bottom Line:
NestJS's dependency injection isn't magic—it's a strict system of rules. Once you understand the mental model (modules as sealed boxes that must explicitly share their contents), most DI errors become obvious.
The error messages can be cryptic, but they're almost always telling you one thing: "This service isn't available in this module's context." Your job is to figure out why, using the checklist above.
Master these concepts, and you'll never blindly import modules again. You'll design clean, maintainable, testable architectures where dependencies flow in one direction and every module has a clear purpose.
Now go forth and inject with confidence!
Top comments (0)