DEV Community

Cover image for NestJS Dependency Injection: Why Your Services Won't Inject (And How to Fix It Properly)
Adam - The Developer
Adam - The Developer

Posted on

NestJS Dependency Injection: Why Your Services Won't Inject (And How to Fix It Properly)

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.
Enter fullscreen mode Exit fullscreen mode

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

  1. The Root Problem: Module Context
  2. Problem #1: Forgetting to Export
  3. Problem #2: Deep Dependency Chains
  4. Problem #3: Circular Dependencies Hell
  5. Problem #4: Repository Not Found
  6. Problem #5: Custom Provider Token Mismatches
  7. Problem #6: Global Modules Done Wrong
  8. Problem #7: Dynamic Modules and forRoot/forFeature
  9. Problem #8: Async Providers and Race Conditions
  10. The Debug Checklist
  11. The Mental Model
  12. Real-World Module Architecture
  13. Common Anti-Patterns to Avoid
  14. The Testing Connection
  15. 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!
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Why it fails:

  • You imported EmailModule into UserModule
  • EmailModule provides EmailService
  • But EmailModule doesn't export EmailService
  • Therefore, UserModule can'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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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"
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

Why it's bad:

  • UserModule now imports things it doesn't directly use
  • If EmailService changes its dependencies tomorrow (adds QueueService), you'll need to update UserModule too
  • Creates tight coupling across modules—changing one affects many
  • Makes refactoring a nightmare
  • Harder to understand what UserService actually 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 {}
Enter fullscreen mode Exit fullscreen mode

Why it works:

  • Each module only imports what its services directly inject
  • UserModule doesn't care about LoggerModule or ConfigModule
  • EmailModule handles its own dependencies
  • Changes to LoggerService dependencies don't affect UserModule
  • 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 {}
Enter fullscreen mode Exit fullscreen mode

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()".
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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!
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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 } });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Mongoose Example

// user.service.ts
@Injectable()
export class UserService {
  constructor(
    @InjectModel(User.name) // ✅ Mongoose version
    private readonly userModel: Model<User>
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Error:

Nest can't resolve dependencies of the UserService (?). Please make sure that the argument dependency at index [0] is available.
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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!
  }
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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!
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Follow the Checklist

  1. Is the service provided?
    • Check EmailModule has EmailService in providers array
  2. Is it exported?
    • Check EmailModule has EmailService in exports array
  3. Is the module imported?
    • Check UserModule imports EmailModule
  4. Custom token?
    • If using @Inject('TOKEN'), verify the token matches exactly
  5. Repository?
    • If it's a TypeORM/Mongoose model, use @InjectRepository() or @InjectModel()
  6. Circular dependency?
    • Check if two modules import each other
  7. 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
});
Enter fullscreen mode Exit fullscreen mode

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
└─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode
  • 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:

  1. It's provided in a module
  2. That module exports it
  3. 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: []
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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 {}
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

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
  ) {}
}
Enter fullscreen mode Exit fullscreen mode

Anti-Pattern #6: forwardRef Everywhere

// ❌ DON'T: forwardRef as your go-to solution
@Module({
  imports: [
    forwardRef(() => ModuleA),
    forwardRef(() => ModuleB),
    forwardRef(() => ModuleC),
  ],
})
export class MyModule {}
Enter fullscreen mode Exit fullscreen mode

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)
    );
  });
});
Enter fullscreen mode Exit fullscreen mode

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...
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
  });
});
Enter fullscreen mode Exit fullscreen mode

Testing Best Practices:

  1. Unit tests - Mock all dependencies, test service logic in isolation
  2. Integration tests - Import real modules, mock only external APIs
  3. 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:

  1. Export what others need - If another module injects it, export it
  2. Import only direct dependencies - Not transitive ones
  3. Circular dependencies = bad design - Refactor, don't hack
  4. ORM repositories need special decorators - @InjectRepository(), @InjectModel()
  5. 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:

  1. Read the error carefully (which service, which dependency, which module)
  2. Check: provided? exported? imported?
  3. Check for custom tokens, repositories, circular dependencies
  4. 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() and forFeature()?

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!


Related Resources:

Top comments (0)