Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Dependency Injection

Chapter 2: Dependency Injection & Modules

Dependency Injection (DI) is like having a smart assistant who brings you the tools you need, when you need them, instead of you having to build or find them yourself. In NestJS, DI is the backbone of modular, testable, and maintainable code. This chapter explores DI in depth, including provider types, scopes, custom providers, advanced module patterns, and real-world testing.

2.1 What is Dependency Injection?

Dependency Injection is a design pattern where dependencies (services, repositories, etc.) are provided to a class, rather than the class creating them itself. This allows for easier testing, maintenance, and flexibility.

Understanding the Problem DI Solves

Without Dependency Injection:
// ❌ Bad: Hard-coded dependency
export class UsersService {
  private logger = new LoggerService();  // Hard to test, hard to swap
  
  createUser(userData: any) {
    this.logger.log('Creating user');
    // ...
  }
}
Problems:
  • Can’t easily test UsersService in isolation
  • Can’t swap LoggerService for a different implementation
  • Tight coupling between UsersService and LoggerService
  • Hard to mock dependencies in tests
With Dependency Injection:
// ✅ Good: Dependency injected
@Injectable()
export class UsersService {
  constructor(private logger: LoggerService) {}  // Injected, not created
  
  createUser(userData: any) {
    this.logger.log('Creating user');
    // ...
  }
}
Benefits:
  • Easy to test (can inject a mock logger)
  • Can swap implementations easily
  • Loose coupling between classes
  • NestJS manages the lifecycle
Analogy:
Imagine a chef (your class) who needs ingredients (dependencies). Without DI, the chef drives to the farm, picks tomatoes, milks the cow, and grinds the flour before cooking a single dish. With DI, a supplier (the NestJS DI container) delivers fresh ingredients to the kitchen door every morning. The chef can focus on cooking, and if you want to test the recipe with organic ingredients, you swap the supplier — the chef’s code never changes. This is exactly what happens when you mock a service in tests: you are swapping the supplier, not rewriting the chef.

How NestJS DI Works

NestJS uses a dependency injection container that:
  1. Registers providers when modules are loaded
  2. Resolves dependencies by analyzing constructor parameters
  3. Creates instances based on provider scope
  4. Manages lifecycle (singleton, request-scoped, transient)
The DI Container Flow:
1. Module loads → Providers registered
2. Class needs dependency → Container looks it up
3. Dependency found → Container creates/retrieves instance
4. Instance injected → Class can use dependency
Benefits of DI:
  • Decouples components: Swap implementations easily
  • Enables easy mocking: Inject fake services in tests
  • Supports modular architecture: Plug-and-play modules
  • Manages lifecycle: Handles creation and destruction
  • Reduces boilerplate: No manual instantiation needed

2.2 Providers in NestJS

Providers are the backbone of DI in NestJS. Any class annotated with @Injectable() can be injected as a dependency. Providers can be services, repositories, factories, or even values.

What is a Provider?

A provider is anything that can be injected as a dependency. The most common type is a service, but providers can also be:
  • Services (business logic)
  • Repositories (data access)
  • Factories (functions that create instances)
  • Values (constants, configuration objects)
  • Classes (can be swapped for other implementations)

Basic Provider Example

import { Injectable } from '@nestjs/common';

@Injectable()  // This decorator makes it a provider
export class LoggerService {
  log(message: string) {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }

  error(message: string, error?: Error) {
    console.error(`[${new Date().toISOString()}] ERROR: ${message}`, error);
  }
}

Injecting a Provider

import { Injectable } from '@nestjs/common';
import { LoggerService } from './logger.service';

@Injectable()
export class AppService {
  // Dependency injected via constructor
  constructor(private logger: LoggerService) {}

  doWork() {
    this.logger.log('Work started');
    // ... do work
    this.logger.log('Work completed');
  }
}
How It Works:
  1. NestJS sees LoggerService in the constructor
  2. Looks up LoggerService in the DI container
  3. Creates an instance (or reuses existing one based on scope)
  4. Injects it into AppService

Registering Providers

Providers must be registered in a module:
import { Module } from '@nestjs/common';
import { AppService } from './app.service';
import { LoggerService } from './logger.service';

@Module({
  providers: [
    AppService,      // Registered as provider
    LoggerService,   // Registered as provider
  ],
})
export class AppModule {}
Diagram: DI Flow
AppService needs LoggerService

Constructor parameter detected

NestJS DI Container looks up LoggerService

LoggerService found in providers array

Instance created/retrieved (based on scope)

Injected into AppService constructor

AppService can use LoggerService
Tip: Use @Injectable() on any class you want to inject elsewhere. Without it, NestJS won’t be able to inject the dependency.

2.3 Provider Scopes

NestJS supports three provider scopes that control how instances are created and shared. Understanding scopes is crucial for building correct applications.

Scope Types

1. Default (Singleton) Scope:
  • One instance per application
  • Created when the app starts
  • Shared across all requests
  • Most common and efficient
2. Request Scope:
  • New instance per HTTP request
  • Created when request starts
  • Destroyed when request ends
  • Useful for request-specific data
3. Transient Scope:
  • New instance every time it’s injected
  • Not shared between consumers
  • Useful for stateless, short-lived logic

Singleton Scope (Default)

The singleton scope is the default and should be your go-to choice for 90%+ of providers. Think of it like a shared printer in an office — there is one printer, and everyone uses it. You do not buy a new printer for every print job.
// No scope option needed -- singleton is the default.
// This constructor runs exactly once during application bootstrap.
@Injectable()
export class ConfigService {
  private config: any;

  constructor() {
    console.log('ConfigService created');  // You will see this log exactly once
    this.config = { apiKey: 'secret' };
  }

  get(key: string) {
    return this.config[key];
  }
}
Characteristics:
  • Created once when app starts, lives until the app shuts down
  • The exact same instance is injected everywhere it is requested
  • Efficient (no repeated creation, no garbage collection churn)
  • State persists across requests — be careful with mutable state
Use When:
  • Stateless services (most services)
  • Configuration services
  • Shared utilities
  • Database connection pools
Common Mistake: Storing per-request data (like the current user) in a singleton. Since the instance is shared across all requests, request-specific data will leak between users. Use request scope for that.

Request Scope

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  private requestId: string;

  constructor() {
    this.requestId = Math.random().toString(36).substring(7);
    console.log(`RequestContextService created for request: ${this.requestId}`);
  }

  getRequestId() {
    return this.requestId;
  }
}
Characteristics:
  • New instance per HTTP request
  • Can access request object
  • Automatically cleaned up after request
  • More memory overhead
Use When:
  • Need request-specific data (current user, tenant context)
  • User context services
  • Request logging with correlation IDs
  • Multi-tenancy scenarios where each request targets a different database
Important: Request-scoped providers cannot be injected into singleton providers directly. This is the “scope bubble” rule — a request-scoped provider “infects” its entire dependency chain upward. If ServiceA (singleton) depends on ServiceB (request-scoped), NestJS will throw an error. You must either make ServiceA request-scoped too, or use the REQUEST injection token to access request data indirectly. Performance Warning: Every request-scoped provider is instantiated per request and garbage-collected when the request completes. In a high-traffic API handling thousands of requests per second, this creates significant overhead. Profile before adopting request scope widely.

Transient Scope

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
  private id: string;

  constructor() {
    this.id = Math.random().toString(36).substring(7);
    console.log(`TransientService created: ${this.id}`);
  }

  getId() {
    return this.id;
  }
}
Characteristics:
  • New instance every injection
  • Not shared between consumers
  • Most memory overhead
  • Fully isolated
Use When:
  • Stateless helpers
  • Calculators
  • Validators
  • Transformers

Scope Comparison

ScopeInstancesLifecycleUse Case
Singleton1 per appApp lifetimeMost services
Request1 per requestRequest lifetimeRequest context
Transient1 per injectionInjection lifetimeStateless helpers
Diagram: Provider Scopes
Singleton:
  App Start → Instance Created → Shared Everywhere → App Shutdown

Request:
  Request 1 → Instance 1 Created → Used → Destroyed
  Request 2 → Instance 2 Created → Used → Destroyed

Transient:
  Injection 1 → Instance 1 Created → Used → Destroyed
  Injection 2 → Instance 2 Created → Used → Destroyed
Decision Framework — Which Scope Do I Need? Ask these questions in order. Stop as soon as you get a “yes”:
  1. Does this provider need to store data that is specific to a single HTTP request (current user, tenant context, request correlation ID)? Use Request scope.
  2. Does each consumer need a guaranteed-unique instance with independent state (e.g., a builder that accumulates state across method calls)? Use Transient scope.
  3. Everything else? Use Singleton (the default). This covers 90%+ of providers.
The Scope Bubble Rule: A request-scoped provider “infects” its entire dependency chain upward. If ServiceA (singleton) depends on ServiceB (request-scoped), NestJS will throw an error. You must either make ServiceA request-scoped too, or restructure to avoid the dependency. Before making anything request-scoped, trace the dependency chain to understand the blast radius.
Scope Interaction Matrix:
Consumer ScopeCan Inject Singleton?Can Inject Request?Can Inject Transient?
SingletonYesNo (throws error)Yes (but same instance always)
RequestYesYesYes
TransientYesYes (if consumer is also request-scoped)Yes

2.4 Custom Providers

Custom providers allow you to control how dependencies are created and injected. This is useful for configuration, factories, and swapping implementations.

Why Custom Providers?

The basic pattern — put @Injectable() on a class, list it in providers, inject via constructor — covers most cases. But sometimes you need more control. Think of custom providers as the “advanced settings” panel: you probably do not need them on day one, but they are essential for production applications. Common scenarios where you need custom providers:
  • Inject configuration values or constants (no class to decorate with @Injectable())
  • Create instances with complex or asynchronous initialization logic (database connections, SDK clients)
  • Swap implementations based on environment (real payment gateway in production, mock in testing)
  • Integrate third-party libraries that you do not control (cannot add @Injectable() to their classes)
  • Create multiple instances of the same class with different configurations

Value Provider

Inject a static value (e.g., config object, constants):
import { Module } from '@nestjs/common';

const CONFIG_TOKEN = 'CONFIG';

@Module({
  providers: [
    {
      provide: CONFIG_TOKEN,  // Token to identify this provider
      useValue: {              // Static value to inject
        apiKey: 'secret-key',
        dbHost: 'localhost',
        dbPort: 5432,
      },
    },
  ],
})
export class ConfigModule {}
Using the Value Provider:
import { Injectable, Inject } from '@nestjs/common';

const CONFIG_TOKEN = 'CONFIG';

@Injectable()
export class ApiService {
  constructor(
    @Inject(CONFIG_TOKEN) private config: any,  // Inject using token
  ) {}

  makeRequest() {
    const apiKey = this.config.apiKey;
    // Use config...
  }
}
Use Cases:
  • Configuration objects
  • Constants
  • Environment-specific values
  • Third-party library instances

Factory Provider

Inject a value created by a function (can be async):
import { Module } from '@nestjs/common';

@Module({
  providers: [
{
      provide: 'DATABASE_CONNECTION',
  useFactory: async () => {
        // Can be async
        const connection = await createDatabaseConnection();
        return connection;
      },
      inject: [ConfigService],  // Inject dependencies into factory
    },
  ],
})
export class DatabaseModule {}
Factory with Dependencies:
@Module({
  providers: [
    ConfigService,
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: (config: ConfigService) => {
        // ConfigService is injected into factory
        return createConnection({
          host: config.get('DB_HOST'),
          port: config.get('DB_PORT'),
        });
      },
      inject: [ConfigService],  // Dependencies for factory
    },
  ],
})
export class DatabaseModule {}
Use Cases:
  • Async initialization
  • Complex object creation
  • Conditional logic
  • Integration with third-party libraries

Class Provider

Swap one class for another (e.g., for testing or different implementations):
// Base interface
export interface ILogger {
  log(message: string): void;
}

// Default implementation
@Injectable()
export class LoggerService implements ILogger {
  log(message: string) {
    console.log(message);
  }
}

// Alternative implementation
@Injectable()
export class AdvancedLoggerService implements ILogger {
  log(message: string) {
    // Log to file, database, etc.
    console.log(`[ADVANCED] ${message}`);
  }
}

// Module with class provider
@Module({
  providers: [
    {
      provide: LoggerService,           // Token (the class)
      useClass: AdvancedLoggerService,  // Implementation to use
    },
  ],
})
export class LoggerModule {}
Use Cases:
  • Swapping implementations (dev vs prod)
  • Testing (mock implementations)
  • Strategy pattern
  • A/B testing different implementations

Provider Token Best Practices

Use string tokens for values:
const CONFIG_TOKEN = 'CONFIG';  // String token

{
  provide: CONFIG_TOKEN,
  useValue: { ... },
}
Use class tokens for services:
{
  provide: LoggerService,  // Class as token
  useClass: AdvancedLoggerService,
}
Use symbol tokens for better type safety:
export const CONFIG_TOKEN = Symbol('CONFIG');

{
  provide: CONFIG_TOKEN,
  useValue: { ... },
}
Custom Provider Decision Framework:
I need to…Provider TypeExample
Inject a static value, constant, or config objectuseValueAPI keys, feature flags, config objects
Inject a class but swap the implementationuseClassMock services in test, strategy pattern
Create an instance with complex or async logicuseFactoryDatabase connections, SDK clients
Alias one provider to another tokenuseExistingProvide the same instance under two tokens
Decision flow:
  Is it a plain value (no instantiation needed)? --> useValue
  Is it a class that needs to be swapped?        --> useClass
  Does creation require async work or logic?     --> useFactory
  Do you need two tokens pointing to one thing?  --> useExisting

2.5 Injection Tokens

Tokens are used to identify providers in the DI container. Understanding tokens is essential for custom providers and advanced DI patterns.

Default Token (Class)

By default, the class itself is the token:
@Injectable()
export class UsersService {}

// In constructor, UsersService is both the type and the token
constructor(private usersService: UsersService) {}

String Tokens

Use string tokens for values and non-class providers:
const CONFIG_TOKEN = 'CONFIG';

@Module({
  providers: [
    {
      provide: CONFIG_TOKEN,  // String token
      useValue: { apiKey: 'secret' },
    },
  ],
})
export class ConfigModule {}

// Inject using @Inject decorator
constructor(@Inject(CONFIG_TOKEN) private config: any) {}

Symbol Tokens

Symbols provide better type safety and avoid naming conflicts:
export const CONFIG_TOKEN = Symbol('CONFIG');

@Module({
  providers: [
    {
      provide: CONFIG_TOKEN,  // Symbol token
      useValue: { apiKey: 'secret' },
    },
  ],
})
export class ConfigModule {}

// Inject using symbol
constructor(@Inject(CONFIG_TOKEN) private config: any) {}

Injection Token Patterns

Pattern 1: Interface Token (using string/symbol)
// Define token
export const I_USER_REPOSITORY = Symbol('IUserRepository');

// Interface
export interface IUserRepository {
  findById(id: number): Promise<User>;
}

// Implementation
@Injectable()
export class UserRepository implements IUserRepository {
  async findById(id: number) {
    // ...
  }
}

// Register with token
@Module({
  providers: [
    {
      provide: I_USER_REPOSITORY,
      useClass: UserRepository,
    },
  ],
})
export class UsersModule {}

// Inject using token
constructor(@Inject(I_USER_REPOSITORY) private userRepo: IUserRepository) {}
Best Practice: Use string or symbol tokens for values, and class tokens for services. Use interfaces with tokens for better abstraction.

2.6 Dynamic Modules

Dynamic modules allow you to configure modules at runtime, useful for libraries and shared infrastructure. They let you pass options when importing a module.

Why Dynamic Modules?

Static modules are fine when configuration doesn’t change, but sometimes you need:
  • Database connections with different configs
  • Feature flags
  • Environment-specific settings
  • Reusable libraries that need configuration

Basic Dynamic Module

import { Module, DynamicModule } from '@nestjs/common';

@Module({})
export class DatabaseModule {
  static forRoot(options: DatabaseOptions): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [
        {
          provide: 'DATABASE_OPTIONS',
          useValue: options,
        },
        DatabaseService,
      ],
      exports: [DatabaseService],
      global: true,  // Make it global (optional)
    };
  }
}
Using the Dynamic Module:
@Module({
  imports: [
    DatabaseModule.forRoot({
      host: 'localhost',
      port: 5432,
      database: 'mydb',
    }),
  ],
})
export class AppModule {}

Advanced: forRootAsync

For async configuration (e.g., loading from remote source):
@Module({})
export class DatabaseModule {
  static forRootAsync(options: {
    useFactory: (...args: any[]) => Promise<DatabaseOptions> | DatabaseOptions;
    inject?: any[];
  }): DynamicModule {
    return {
      module: DatabaseModule,
      providers: [
        {
          provide: 'DATABASE_OPTIONS',
          useFactory: options.useFactory,
          inject: options.inject || [],
        },
        DatabaseService,
      ],
      exports: [DatabaseService],
    };
  }
}
Usage:
@Module({
  imports: [
    DatabaseModule.forRootAsync({
      useFactory: async (config: ConfigService) => {
        return {
          host: config.get('DB_HOST'),
          port: config.get('DB_PORT'),
        };
      },
      inject: [ConfigService],
    }),
  ],
})
export class AppModule {}
Analogy:
Think of dynamic modules as ordering from a menu with customization. A static module is a fixed combo meal — you get what you get. A dynamic module is “build your own” — you pick the database host, the port, whether to enable logging. The kitchen (NestJS) takes your order and assembles the module with your specific configuration. This is why most NestJS libraries (TypeORM, Config, Bull) use the forRoot() / forRootAsync() pattern — they need your configuration before they can set up their providers.
Common Patterns:
  • forRoot() - Synchronous configuration
  • forRootAsync() - Asynchronous configuration
  • forFeature() - Feature-specific configuration

2.7 Module Architecture & Advanced Patterns

Modules group related providers and controllers. Understanding module patterns helps you build scalable, maintainable applications.

Feature Modules

Organize your code by business domain (e.g., UsersModule, OrdersModule). Each module should encapsulate its own providers, controllers, and entities. Example: Users Feature Module
// users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { UserRepository } from './user.repository';

@Module({
  controllers: [UsersController],
  providers: [UsersService, UserRepository],
  exports: [UsersService],  // Export for other modules
})
export class UsersModule {}
Benefits:
  • Clear boundaries
  • Easy to find related code
  • Can be developed independently
  • Easy to test in isolation

Shared Modules

Shared modules export common providers (e.g., ConfigService, LoggerService) for use in other modules.
// shared/shared.module.ts
import { Module } from '@nestjs/common';
import { LoggerService } from './logger.service';
import { ConfigService } from './config.service';

@Module({
  providers: [LoggerService, ConfigService],
  exports: [LoggerService, ConfigService],  // Export for other modules
})
export class SharedModule {}
Using Shared Module:
// users/users.module.ts
import { Module } from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { UsersService } from './users.service';

@Module({
  imports: [SharedModule],  // Import shared module
  providers: [UsersService],
})
export class UsersModule {
  // UsersService can now inject LoggerService and ConfigService
}
Best Practice: Always use the exports array to make providers available outside the module. Only export what’s needed.

Global Modules

Make a module available everywhere without importing:
import { Module, Global } from '@nestjs/common';

@Global()  // Makes module global
@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}
Use Sparingly: Global modules can make dependencies unclear. Use only for truly global services (like ConfigService).

Re-exporting Modules

You can re-export entire modules to compose features:
// core/core.module.ts
import { Module } from '@nestjs/common';
import { SharedModule } from '../shared/shared.module';
import { DatabaseModule } from '../database/database.module';

@Module({
  imports: [SharedModule, DatabaseModule],
  exports: [SharedModule, DatabaseModule],  // Re-export
})
export class CoreModule {}
Usage:
@Module({
  imports: [CoreModule],  // Gets both SharedModule and DatabaseModule
})
export class AppModule {}

Handling Circular Dependencies

Circular dependencies occur when two providers depend on each other. Use forwardRef to resolve them:
// auth/auth.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [forwardRef(() => UsersModule)],  // Forward reference
  providers: [AuthService],
  exports: [AuthService],
})
export class AuthModule {}

// users/users.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthModule } from '../auth/auth.module';

@Module({
  imports: [forwardRef(() => AuthModule)],  // Forward reference
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}
Also use forwardRef in providers:
@Injectable()
export class AuthService {
  constructor(
    @Inject(forwardRef(() => UsersService))
    private usersService: UsersService,
  ) {}
}
Best Practice: Avoid circular dependencies when possible. They are almost always a sign that two modules are doing related work that should be extracted into a shared module. If you find yourself reaching for forwardRef, pause and ask: “Can I move the shared logic into a third module that both can import?” Nine times out of ten, the answer is yes, and the resulting architecture is cleaner. Use forwardRef only as a last resort for genuinely bidirectional relationships. Diagram: Module Relationships
AppModule
  ├── UsersModule
  │   ├── UsersController
  │   ├── UsersService
  │   └── UserRepository
  ├── AuthModule
  │   ├── AuthController
  │   └── AuthService (uses UsersService)
  └── SharedModule (exports LoggerService, ConfigService)
      └── Used by UsersModule and AuthModule
Best Practices:
  • Keep modules focused and cohesive
  • Export only what’s needed
  • Use shared modules for cross-cutting concerns
  • Avoid circular dependencies; use forwardRef only when necessary
  • Use global modules sparingly

2.8 Real-World Example: Config Service & Advanced Providers

Let’s see how to build a flexible configuration system using DI and custom providers.

Basic Config Service

// config/config.service.ts
import { Injectable } from '@nestjs/common';

@Injectable()
export class ConfigService {
  private readonly config = {
    apiKey: process.env.API_KEY,
    dbHost: process.env.DB_HOST || 'localhost',
    dbPort: parseInt(process.env.DB_PORT || '5432', 10),
    logLevel: process.env.LOG_LEVEL || 'info',
  };

  get<T = any>(key: string): T {
    return this.config[key];
  }

  getOrThrow<T = any>(key: string): T {
    const value = this.config[key];
    if (value === undefined) {
      throw new Error(`Configuration key "${key}" is required`);
    }
    return value;
  }
}

Config Module

// config/config.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from './config.service';

@Global()  // Make it global so it's available everywhere
@Module({
  providers: [ConfigService],
  exports: [ConfigService],
})
export class ConfigModule {}

Using Config Service

// users/users.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '../config/config.service';

@Injectable()
export class UsersService {
  constructor(private config: ConfigService) {}

  async connectToDatabase() {
    const dbHost = this.config.get<string>('dbHost');
    const dbPort = this.config.get<number>('dbPort');
    // Connect...
  }
}

Advanced: Async Factory Provider for Config

// config/config.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigService } from './config.service';

@Global()
@Module({
  providers: [
    {
      provide: ConfigService,
      useFactory: async () => {
        // Can load from remote source, file, etc.
        const config = await loadConfigFromRemote();
        return new ConfigService(config);
      },
    },
  ],
  exports: [ConfigService],
})
export class ConfigModule {}

Using String Token for Config

// config/config.module.ts
const CONFIG_TOKEN = 'CONFIG';

@Module({
  providers: [
    {
      provide: CONFIG_TOKEN,
      useFactory: async () => {
        return await loadConfig();
      },
    },
  ],
  exports: [CONFIG_TOKEN],
})
export class ConfigModule {}

// Usage
import { Inject } from '@nestjs/common';
const CONFIG_TOKEN = 'CONFIG';

@Injectable()
export class ApiService {
  constructor(@Inject(CONFIG_TOKEN) private config: any) {}
}

2.9 Testing Providers & Modules

Testing DI is easy with Nest’s testing utilities. Use Test.createTestingModule to mock providers and test modules in isolation.

Basic Service Test

import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from './config.service';

describe('ConfigService', () => {
  let service: ConfigService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [ConfigService],
    }).compile();

    service = module.get<ConfigService>(ConfigService);
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  it('should return config value', () => {
    process.env.API_KEY = 'test-key';
    const value = service.get('apiKey');
    expect(value).toBe('test-key');
  });
});

Mocking Providers

import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { ConfigService } from './config.service';

describe('UsersService', () => {
  let service: UsersService;
  let configService: ConfigService;

  beforeEach(async () => {
    // Create mock
    const mockConfigService = {
      get: jest.fn().mockReturnValue('mocked-value'),
    };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: ConfigService,
          useValue: mockConfigService,  // Use mock instead of real
        },
      ],
    }).compile();

    service = module.get<UsersService>(UsersService);
    configService = module.get<ConfigService>(ConfigService);
  });

  it('should use mocked config', () => {
    service.doSomething();
    expect(configService.get).toHaveBeenCalled();
  });
});

Testing with Modules

import { Test, TestingModule } from '@nestjs/testing';
import { UsersModule } from './users.module';
import { UsersService } from './users.service';

describe('UsersModule', () => {
  let module: TestingModule;

  beforeEach(async () => {
    module = await Test.createTestingModule({
      imports: [UsersModule],  // Import the actual module
    }).compile();
  });

  it('should have UsersService', () => {
    const service = module.get<UsersService>(UsersService);
    expect(service).toBeDefined();
  });
});

Testing Custom Providers

describe('Service with Custom Provider', () => {
  let service: ApiService;

  beforeEach(async () => {
const module = await Test.createTestingModule({
  providers: [
        ApiService,
        {
          provide: 'CONFIG',
          useValue: { apiKey: 'test-key' },
        },
  ],
}).compile();

    service = module.get<ApiService>(ApiService);
  });

  it('should use custom provider', () => {
    expect(service).toBeDefined();
  });
});
Tip: Always mock external dependencies (like databases or APIs) in your tests for speed and reliability. Use real implementations only in integration tests.

2.10 DI Edge Cases

Edge Case 1: Injecting into non-NestJS classes If you have a plain TypeScript class (not registered as a provider) that needs a service, you cannot use constructor injection. Options: (1) Register the class as a provider; (2) Pass the dependency as a method parameter; (3) Use ModuleRef to resolve providers dynamically. Never use the app.get() hack to access providers outside the DI container — it breaks testability and creates hidden dependencies. Edge Case 2: Multiple providers with the same interface If you have PostgresUserRepository and MongoUserRepository, both implementing IUserRepository, you cannot register both under the same token. Use named tokens:
@Module({
  providers: [
    { provide: 'POSTGRES_REPO', useClass: PostgresUserRepository },
    { provide: 'MONGO_REPO', useClass: MongoUserRepository },
  ],
})
Or use a factory provider that selects the implementation based on configuration. Edge Case 3: onModuleInit ordering between modules If Module A imports Module B, Module B’s onModuleInit runs before Module A’s. But if Module A and Module B are both imported by AppModule with no dependency between them, the order is not guaranteed. If Module A’s initialization depends on Module B being ready, Module A must explicitly import Module B. Edge Case 4: Dynamic module forRoot called multiple times If two feature modules both import DatabaseModule.forRoot({ ... }) with different configs, you get two database connections (or an error, depending on the module implementation). This is why most infrastructure modules use global: true in forRoot() and provide a separate forFeature() for feature-specific registration. Follow this convention: forRoot() once in AppModule, forFeature() in each feature module. Edge Case 5: Testing a provider that uses @Optional() dependencies When a dependency is marked @Optional(), the provider works without it in production. But in tests, Test.createTestingModule() still tries to resolve it. If the optional dependency is from a module you did not import in your test, provide a useValue: undefined to explicitly skip it.

2.11 Common Pitfalls and Solutions

Pitfall 1: Forgetting to Export Providers

// ❌ Bad: Service not exported
@Module({
  providers: [UsersService],
  // Missing exports!
})
export class UsersModule {}

// ✅ Good: Export what others need
@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Pitfall 2: Circular Dependencies

// ❌ Bad: Direct circular dependency
@Module({
  imports: [AuthModule],  // AuthModule imports UsersModule
})
export class UsersModule {}

// ✅ Good: Use forwardRef
@Module({
  imports: [forwardRef(() => AuthModule)],
})
export class UsersModule {}

Pitfall 3: Request-Scoped in Singleton

// ❌ Bad: Can't inject request-scoped into singleton
@Injectable()
export class SingletonService {
  constructor(private requestService: RequestService) {}  // Error!
}

// ✅ Good: Make consumer request-scoped or use REQUEST provider
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
  constructor(private requestService: RequestService) {}  // OK
}

Pitfall 4: Missing @Injectable()

// ❌ Bad: Missing decorator
export class UsersService {}

// ✅ Good: Add decorator
@Injectable()
export class UsersService {}

2.11 Summary

You’ve learned how dependency injection powers modular, testable, and scalable applications in NestJS: Key Concepts:
  • Providers: Classes, values, or factories that can be injected
  • Scopes: Singleton (default), Request, and Transient
  • Custom Providers: Value, Factory, and Class providers
  • Injection Tokens: Class, string, or symbol tokens
  • Dynamic Modules: Configurable modules with forRoot() and forRootAsync()
  • Module Patterns: Feature modules, shared modules, global modules
Best Practices:
  • Use singleton scope for most services
  • Export only what’s needed from modules
  • Use custom providers for configuration and third-party integrations
  • Avoid circular dependencies when possible
  • Mock dependencies in unit tests
Next Chapter: Learn about controllers, routing, request handling, validation, middleware, guards, and interceptors in depth.

Interview Deep-Dive

Strong Answer:
  • Singleton (default): one instance per application lifetime, shared across all requests. This is correct for 90%+ of providers — your UsersService, ConfigService, database repositories are all singletons. The instance is created once during bootstrap and reused.
  • Request-scoped (Scope.REQUEST): a new instance is created for every incoming HTTP request and garbage-collected when the response is sent. This is necessary when you need per-request state — the canonical example is a multi-tenant application where each request targets a different database based on the tenant ID in the JWT token.
  • Transient (Scope.TRANSIENT): a new instance is created every time the provider is injected. If three different services inject a transient provider, they each get a different instance. This is useful for stateless utility classes where you explicitly want isolation.
  • The production issue I have seen with request-scoped providers is the “scope bubble” problem. If you make TenantService request-scoped, every provider that depends on it ALSO becomes request-scoped — NestJS forces this because a singleton cannot hold a reference to something that changes per request. In one project, making one service request-scoped cascaded through 15 other services, turning them all request-scoped. At 2,000 requests per second, this created 30,000 object instantiations per second, and GC pauses jumped from 5ms to 200ms, causing p99 latency to spike. The fix was to inject the REQUEST token directly into the singleton services that needed request context, rather than making a shared service request-scoped.
Follow-up: How would you inject per-request data (like the current user) into a singleton service without making it request-scoped?Use the REQUEST injection token with @Inject(REQUEST) in a request-scoped provider, then pass the data explicitly as a method parameter to singleton services. Alternatively, use AsyncLocalStorage (Node.js built-in) to create a request context that any code in the call chain can access without DI. NestJS’s @nestjs/core provides ContextIdFactory for advanced use cases. The cleanest pattern I have used is a thin request-scoped RequestContextService that stores the user and tenant ID, and then the singleton services receive these as method parameters rather than injecting the context service directly. This keeps the scope bubble small — only one provider is request-scoped instead of the entire dependency chain.
Strong Answer:
  • I would use a factory provider (useFactory) because the payment gateway client requires async initialization (API key validation, connection handshake) and depends on configuration values.
  • The provider definition would look like: { provide: 'PAYMENT_GATEWAY', useFactory: async (config: ConfigService) => { const client = new StripeClient(config.getOrThrow('STRIPE_SECRET_KEY')); await client.verify(); return client; }, inject: [ConfigService] }. The inject array tells NestJS which providers to pass as arguments to the factory function.
  • I use getOrThrow instead of get because if the Stripe key is missing, I want the application to crash at startup, not serve requests that silently fail when they try to charge a card. This is the “fail fast” principle.
  • I use a string token ('PAYMENT_GATEWAY') instead of a class token because the StripeClient class comes from a third-party library — I cannot add @Injectable() to it. Consumers inject it with @Inject('PAYMENT_GATEWAY') private stripe: StripeClient.
  • For testing, I would register a mock payment gateway in the test module: { provide: 'PAYMENT_GATEWAY', useValue: { charge: jest.fn().mockResolvedValue({ id: 'ch_test', status: 'succeeded' }) } }. This lets me test the order processing flow without making real API calls.
  • In production, I would also add a health check that calls stripe.balance.retrieve() to verify the connection is alive.
Follow-up: What if you need two different Stripe clients in the same application — one for the US entity and one for the EU entity, each with different API keys?Create two separate tokens: 'PAYMENT_GATEWAY_US' and 'PAYMENT_GATEWAY_EU', each with its own factory provider reading different config keys. Alternatively, use a dynamic module pattern: PaymentModule.forRoot({ region: 'US', secretKey: 'STRIPE_US_KEY' }) that registers the provider with a region-specific token. The consumer then injects @Inject('PAYMENT_GATEWAY_US') or @Inject('PAYMENT_GATEWAY_EU'). If the region is determined at request time (multi-tenant), use a request-scoped factory that reads the region from the request context and returns the appropriate client.
Strong Answer:
  • forRoot() is called once in the root module to configure a global, shared resource. Think of it as “set up the infrastructure.” For example, TypeOrmModule.forRoot({ host: 'localhost', ... }) creates the database connection pool once and makes it available globally. You call this in AppModule.
  • forFeature() is called in each feature module to register module-specific resources that depend on the global infrastructure. TypeOrmModule.forFeature([User, Post]) tells TypeORM which entities this module uses and creates the repositories for them. You call this in UsersModule, PostsModule, etc.
  • The pattern works like a restaurant chain. forRoot() is opening the restaurant (signing the lease, setting up the kitchen). forFeature() is adding items to the menu — each chef (module) adds their dishes, but they all share the same kitchen.
  • Under the hood, forRoot() typically returns a DynamicModule with global: true, which makes its exports available everywhere without importing. forFeature() returns a non-global module whose exports are available only to the importing module.
  • There is also forRootAsync() for async configuration. This is essential in production because your database credentials come from ConfigService, which itself needs to be initialized first. The pattern is: TypeOrmModule.forRootAsync({ useFactory: (config) => ({ host: config.get('DB_HOST') }), inject: [ConfigService] }).
Follow-up: What happens if you accidentally call forRoot() twice in different modules?For most NestJS packages, calling forRoot() twice creates two separate instances of the underlying resource. With TypeORM, that means two connection pools, which doubles your database connections and can exhaust the connection limit. Some packages guard against this (ConfigModule logs a warning), but most do not. The convention is: forRoot() once in AppModule, forFeature() everywhere else. If you need to share configuration across modules, make the root module global with global: true in the dynamic module definition.
Strong Answer:
  • forwardRef() is a bandage, not a cure. It tells NestJS to “resolve this reference later” to break the initialization cycle, but it does not fix the underlying design problem — two modules that are conceptually coupled.
  • The first step is to draw the dependency graph. If AuthModule needs UsersService (to validate credentials) and UsersModule needs AuthService (to hash passwords during registration), the circle is clear. The question is: what is the shared concern?
  • In this case, the shared concern is password hashing. HashService does not depend on either AuthModule or UsersModule — it is a pure utility. Extract it into a SharedModule or CryptoModule. Now AuthModule imports SharedModule for password comparison, and UsersModule imports SharedModule for password hashing. The circle is broken.
  • Another common pattern: AuthModule needs to look up users, and UsersModule needs to check authentication for certain operations. The fix is to have AuthModule depend on UsersModule (import it to get UsersService), and for the auth-check in UsersModule, use guards (which are provided globally or by the AuthModule) instead of directly importing AuthModule. Guards do not create a module dependency — they are applied via decorators.
  • The general principle: if two modules depend on each other, either (1) extract shared logic into a third module, (2) merge the modules if they are truly inseparable, or (3) use events instead of direct calls (Service A emits an event, Service B listens to it, no import needed).
Follow-up: Is there ever a legitimate use case for forwardRef()?Yes, but it is rare. The one legitimate case is bidirectional entity relationships in TypeORM. If User has @OneToMany(() => Post) and Post has @ManyToOne(() => User), TypeORM uses forwardRef-style lazy arrow functions to handle the circular type reference. This is a TypeScript limitation, not a design smell. For NestJS module-level circular dependencies, I have never seen a case where refactoring was not the better solution.