Back to all posts

Dependency Management and Inversion of Control

S
sonhp
10 min read

Dependency Management and Inversion of Control

Managing dependencies properly is key to writing scalable, testable, and maintainable applications.

In this guide, we’ll cover:

  • What Dependency Injection (DI) and Inversion of Control (IoC) mean
  • Why NestJS encourages DI by default
  • Common anti-patterns in dependency management
  • Best practices for managing dependencies
  • Handling third-party dependencies properly

1. What is Dependency Injection (DI)?

Dependency Injection is a design pattern that promotes loose coupling by injecting dependencies instead of instantiating them manually.

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}
}

Here, UserRepository is injected into UserService instead of being created inside the class.


Bad: Instantiating Dependencies Manually

export class UserService {
  private userRepository = new UserRepository(); // ❌ Bad practice
}

🚨 Problem: The UserService is now tightly coupled to UserRepository, making it hard to test and replace.


2. What is Inversion of Control (IoC)?

Inversion of Control (IoC) is a broader principle where the framework controls the creation and lifecycle of dependencies, not your code.

NestJS implements IoC through its dependency injection container, handling object creation and resolution automatically.


3. Common Anti-Patterns in Dependency Management

Service-to-Service Direct Instantiation

export class OrderService {
  private userService = new UserService(); // ❌ Wrong
}

🚨 Problem: OrderService is now tightly coupled to UserService, making unit testing difficult.

Use DI Instead:

@Injectable()
export class OrderService {
  constructor(private readonly userService: UserService) {}
}

Using Static Methods for Dependencies

export class LoggerService {
  static log(message: string) {
    console.log(message);
  }
}

🚨 Problem: Static methods are hard to mock in unit tests.

Use an Injectable Service Instead

@Injectable()
export class LoggerService {
  log(message: string) {
    console.log(message);
  }
}

Now, LoggerService can be injected and replaced easily.


4. NestJS Dependency Injection in Action

Step 1: Register the Provider

Create a repository and service.

📌 user.repository.ts

@Injectable()
export class UserRepository {
  findUserById(id: string) {
    return { id, name: 'John Doe' };
  }
}

Step 2: Inject It into a Service

📌 user.service.ts

@Injectable()
export class UserService {
  constructor(private readonly userRepository: UserRepository) {}

  getUser(id: string) {
    return this.userRepository.findUserById(id);
  }
}

Step 3: Register Services in a Module

📌 user.module.ts

import { Module } from '@nestjs/common';
import { UserService } from './user.service';
import { UserRepository } from './user.repository';

@Module({
  providers: [UserService, UserRepository],
  exports: [UserService],
})
export class UserModule {}

Now, NestJS manages the lifecycle of UserService and UserRepository.


5. Managing Third-Party Dependencies

When working with external libraries (e.g., database connections, HTTP clients), follow these best practices:

Use Factory Providers for Configuration

For example, registering a database connection:

📌 database.module.ts

import { Module } from '@nestjs/common';
import { createConnection } from 'typeorm';

@Module({
  providers: [
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: async () =>
        await createConnection({
          type: 'mysql',
          host: 'localhost',
          username: 'root',
          password: 'password',
          database: 'app',
        }),
    },
  ],
  exports: ['DATABASE_CONNECTION'],
})
export class DatabaseModule {}

Why? This ensures dependency injection works properly instead of using new DatabaseConnection() directly.


6. Avoiding Circular Dependencies

A circular dependency happens when two services depend on each other.

Example of Circular Dependency

@Injectable()
export class UserService {
  constructor(private readonly orderService: OrderService) {} // ❌
}

@Injectable()
export class OrderService {
  constructor(private readonly userService: UserService) {} // ❌
}

Solution 1: Use Forward References

@Module({
  providers: [
    UserService,
    {
      provide: 'OrderService',
      useClass: OrderService,
    },
  ],
})
export class UserModule {}

Solution 2: Extract Shared Logic Into a Separate Module

If UserService and OrderService share dependencies, move them into a common module.


7. Summary: Best Practices for Dependency Management

Use Dependency Injection (DI) instead of manual instantiation
Avoid service-to-service dependencies – use modules for separation
Register third-party services properly using providers
Use factory providers for configuration-based dependencies
Resolve circular dependencies using forward references or shared modules

S

Written by sonhp

Technical writer and developer passionate about web technologies.

Related Articles