I was, knee-deep in the dependency injection system of NestJS, trying to figure out how it all worked. You know that feeling when you’re exploring something and suddenly realize there are concepts you kind of know, but not really? That’s where I was with Dependency Inversion, Inversion of Control, and Dependency Injection.
These three ideas seem so similar at first. But the deeper I dug, the more it became clear: they’re connected, but they solve different problems. I figured I’d write this as a refresher for myself—and for anyone else who’s been staring at these terms and wondering how they all fit together.
1. Dependency Inversion Principle (DIP)
Definition:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
What Does This Mean?
In software, a high-level module contains core business logic, while low-level modules handle specific implementation details (e.g., file systems, databases, or APIs). Without DIP, high-level modules directly depend on low-level modules, creating tight coupling that:
- Reduces flexibility.
- Complicates testing and maintenance.
- Increases effort to replace or extend low-level details.
DIP flips this relationship. Instead of the high-level module controlling low-level implementation, both depend on a shared abstraction (like an interface or abstract class).
Without DIP
Python Example
class EmailService:
def send_email(self, message):
print(f"Sending email: {message}")
class Notification:
def __init__(self):
self.email_service = EmailService()
def notify(self, message):
self.email_service.send_email(message)
TypeScript Example
class EmailService {
sendEmail(message: string): void {
console.log(`Sending email: ${message}`);
}
}
class Notification {
private emailService: EmailService;
constructor() {
this.emailService = new EmailService();
}
notify(message: string): void {
this.emailService.sendEmail(message);
}
}
Problems:
- Tight coupling:
Notification
depends onEmailService
. - Hard to extend: Switching to an
SMSService
or aPushNotificationService
requires modifyingNotification
.
With DIP
Python Example
from abc import ABC, abstractmethod
class MessageService(ABC):
@abstractmethod
def send_message(self, message):
pass
class EmailService(MessageService):
def send_message(self, message):
print(f"Sending email: {message}")
class Notification:
def __init__(self, message_service: MessageService):
self.message_service = message_service
def notify(self, message):
self.message_service.send_message(message)
# Usage
email_service = EmailService()
notification = Notification(email_service)
notification.notify("Hello, Dependency Inversion!")
TypeScript Example
interface MessageService {
sendMessage(message: string): void;
}
class EmailService implements MessageService {
sendMessage(message: string): void {
console.log(`Sending email: ${message}`);
}
}
class Notification {
private messageService: MessageService;
constructor(messageService: MessageService) {
this.messageService = messageService;
}
notify(message: string): void {
this.messageService.sendMessage(message);
}
}
// Usage
const emailService = new EmailService();
const notification = new Notification(emailService);
notification.notify("Hello, Dependency Inversion in TypeScript!");
Benefits of DIP
- Flexibility: Swap implementations without modifying high-level modules.
- Testability: Replace real dependencies with mock versions for testing.
- Maintainability: Changes in low-level modules don’t affect high-level modules.
2. Inversion of Control (IoC)
IoC is a design principle where the control of dependencies is transferred to an external system or framework, rather than managed within the class itself.
In traditional programming, a class creates and manages its dependencies. IoC flips this control—an external entity (e.g., a framework or container) manages dependencies and injects them where needed.
Without IoC
In a setup without IoC, the class itself is responsible for creating and managing its dependencies, leading to tight coupling. Let’s see examples in Python and TypeScript:
Python Example: Without IoC
class SMSService:
def send_message(self, message):
print(f"Sending SMS: {message}")
class Notification:
def __init__(self):
# Dependency is created inside the class
self.sms_service = SMSService()
def notify(self, message):
self.sms_service.send_message(message)
# Usage
notification = Notification()
notification.notify("Hello, tightly coupled dependencies!")
TypeScript Example: Without IoC
class SMSService {
sendMessage(message: string): void {
console.log(`Sending SMS: ${message}`);
}
}
class Notification {
private smsService: SMSService;
constructor() {
// Dependency is created inside the class
this.smsService = new SMSService();
}
notify(message: string): void {
this.smsService.sendMessage(message);
}
}
// Usage
const notification = new Notification();
notification.notify("Hello, tightly coupled dependencies!");
Problems Without IoC:
- Tight coupling: The
Notification
class directly creates and depends on theSMSService
class. - Low flexibility: Switching to a different implementation (e.g.,
EmailService
) requires modifying theNotification
class. - Difficult testing: Mocking dependencies for unit tests is challenging because the dependencies are hardcoded.
With IoC
In the With IoC examples, we shift the responsibility of managing dependencies to an external system or a framework, achieving loose coupling and enhancing testability.
Python Example: With IoC
class SMSService:
def send_message(self, message):
print(f"Sending SMS: {message}")
class Notification:
def __init__(self, message_service):
# Dependency is injected externally
self.message_service = message_service
def notify(self, message):
self.message_service.send_message(message)
# IoC: Dependency is controlled externally
sms_service = SMSService()
notification = Notification(sms_service)
notification.notify("Hello, Inversion of Control!")
TypeScript Example: With IoC
class SMSService {
sendMessage(message: string): void {
console.log(`Sending SMS: ${message}`);
}
}
class Notification {
private messageService: SMSService;
constructor(messageService: SMSService) {
// Dependency is injected externally
this.messageService = messageService;
}
notify(message: string): void {
this.messageService.sendMessage(message);
}
}
// IoC: Dependency is controlled externally
const smsService = new SMSService();
const notification = new Notification(smsService);
notification.notify("Hello, Inversion of Control in TypeScript!");
Benefits of IoC
- Loose coupling: Classes don’t create their dependencies, making them less dependent on specific implementations.
- Easy to switch implementations: Replace a
SMSService
with anEmailService
without modifying the core class. - Improved testability: Inject mock or fake dependencies during testing.
3. Dependency Injection (DI)
DI is a technique where an object receives its dependencies from an external source rather than creating them itself.
DI is a practical implementation of IoC. It allows developers to "inject" dependencies into classes in various ways:
- Constructor Injection: Dependencies are passed via the constructor.
- Setter Injection: Dependencies are set via public methods.
- Interface Injection: Dependencies are provided through an interface.
Python Example: DI Framework
Using the injector
library:
from injector import Injector, inject
class EmailService:
def send_message(self, message):
print(f"Email sent: {message}")
class Notification:
@inject
def __init__(self, email_service: EmailService):
self.email_service = email_service
def notify(self, message):
self.email_service.send_message(message)
# DI Container
injector = Injector()
notification = injector.get(Notification)
notification.notify("Dependency Injection in Python!")
TypeScript Example: DI Framework
Using tsyringe
:
import "reflect-metadata";
import { injectable, inject, container } from "tsyringe";
@injectable()
class EmailService {
sendMessage(message: string): void {
console.log(`Email sent: ${message}`);
}
}
@injectable()
class Notification {
constructor(@inject(EmailService) private emailService: EmailService) {}
notify(message: string): void {
this.emailService.sendMessage(message);
}
}
// DI Container
const notification = container.resolve(Notification);
notification.notify("Dependency Injection in TypeScript!");
Benefits of DI
- Simplifies Testing: Easily replace dependencies with mock objects.
- Improves Scalability: Add new implementations without modifying existing code.
- Enhances Maintainability: Reduces the impact of changes in one part of the system.
Author Of article : Amir Ehsan Ahmadzadeh Read full article