Writing Clean and Maintainable Code in NestJS
Part 9 of 12
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.
✅ Good: Using DI (Recommended)
@Injectable()
export class UserService {
constructor(private readonly userRepository: UserRepository) {}
}
Here,
UserRepository
is injected intoUserService
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 toUserRepository
, 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 toUserService
, 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
andUserRepository
.
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
andOrderService
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