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:

  1. Tight coupling: Notification depends on EmailService.
  2. Hard to extend: Switching to an SMSService or a PushNotificationService requires modifying Notification.

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:

  1. Tight coupling: The Notification class directly creates and depends on the SMSService class.
  2. Low flexibility: Switching to a different implementation (e.g., EmailService) requires modifying the Notification class.
  3. 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

  1. Loose coupling: Classes don’t create their dependencies, making them less dependent on specific implementations.
  2. Easy to switch implementations: Replace a SMSService with an EmailService without modifying the core class.
  3. 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:

  1. Constructor Injection: Dependencies are passed via the constructor.
  2. Setter Injection: Dependencies are set via public methods.
  3. 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