DEV Community

Amir Ehsan Ahmadzadeh
Amir Ehsan Ahmadzadeh

Posted on

Breaking Down Dependency Inversion, IoC, and DI

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)
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

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!");
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

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!");
Enter fullscreen mode Exit fullscreen mode

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!")
Enter fullscreen mode Exit fullscreen mode

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!");
Enter fullscreen mode Exit fullscreen mode

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("This is Dependency Injection in Python!")
Enter fullscreen mode Exit fullscreen mode

Note: Be aware that the injector relies on Python type annotations; So it will distinguish dependencies based on type hints.


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("This is Dependency Injection in TypeScript!");
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)