DEV Community

Cover image for From Dependency Inversion to Dependency Injection in Python
Antonis Markoulis
Antonis Markoulis

Posted on

From Dependency Inversion to Dependency Injection in Python

Introduction

The Dependency Inversion Principle (DIP) and Dependency Injection (DI) are powerful concepts that can significantly improve the design of your Python code. In this post, we will explore these principles in detail, starting with a high-level overview and progressing to practical examples. By the end, you’ll have a deeper understanding of how to write modular, maintainable, and flexible Python code that adheres to these best practices.

Let’s start with a famous quote from Robert C. Martin:


“High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend upon abstractions.”

This quote introduces us to the Dependency Inversion Principle (DIP). Let’s explore this principle further.

Dependency Inversion

What is Dependency Inversion?

The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules but should instead rely on abstractions. This principle encourages us to decouple high-level business logic from the specific implementation details that support it. By doing so, we can swap out the underlying implementations without affecting the higher-level logic, making our code more flexible and maintainable.

Now, let’s visualize this with two examples:

Visualizing Dependency Inversion

In the following images, you can see the progression from a tightly coupled design to a design where components rely on interfaces and abstractions:

  • Tightly Coupled Design: In this scenario, components directly depend on one another, making the system rigid and hard to extend or modify without changing all the related components. No inversion
  • Decoupled Design with Dependency Inversion: By introducing abstractions (interfaces), we decouple the components, making the system more flexible. The high-level modules now rely on abstractions, making it easier to replace or update components without breaking the system. With inversion

Initial Design (No Abstraction)

Now that we understand the concept, let’s look at an initial example that does not follow the Dependency Inversion Principle. In the following code, the PizzaFanclass directly handles the process of ordering a pizza, and the main() function is tightly coupled to this specific implementation:

class PizzaFan:
    def order_margherita(self):
        print("Order margherita")

def main():
    pizza_fan = PizzaFan()
    pizza_fan.order_margherita()
Enter fullscreen mode Exit fullscreen mode

This design lacks flexibility because if we want to use a different pizza provider, we would need to modify the main() function. This violates the Dependency Inversion Principle because the high-level module (main) depends on a low-level module (PizzaFan).

Improvement 1: Introducing Abstraction

To reduce the dependency on the low-level module, we can introduce an abstraction. Instead of depending directly on PizzaFan, we define an interface (AbstractPizza) and make both FancyPizza and GourmetPizza implement this interface. Now, the main() function depends on an abstraction (interface) rather than a concrete class.

from abc import ABC, abstractmethod

class AbstractPizza(ABC):
    @abstractmethod
    def order_margherita(self):
        pass

class FancyPizza(AbstractPizza):
    def order_margherita(self):
        print("Order margherita from FancyPizza")

class GourmetPizza(AbstractPizza):
    def order_margherita(self):
        print("Order margherita from GourmetPizza")

def main():
    fancy_pizza = FancyPizza()
    fancy_pizza.order_margherita()

    gourmet_pizza = GourmetPizza()
    gourmet_pizza.order_margherita()

Enter fullscreen mode Exit fullscreen mode

By introducing the AbstractPizza interface, the main() function can now work with any pizza provider that implements this interface. This demonstrates how the Dependency Inversion Principle makes our code more flexible and adaptable to changes.

Improvement 2: Adding Flexibility with Parameters

Now that our main() function is decoupled from the specific pizza providers, let’s take it one step further by adding flexibility to handle different ingredients. In this example, we allow the user to specify ingredients, making the ordering process more dynamic.

from abc import ABC, abstractmethod
from typing import List

class AbstractPizza(ABC):
    @abstractmethod
    def order(self, ingredients: List[str]):
        pass

class FancyPizza(AbstractPizza):
    def order(self, ingredients: List[str]):
        if set(ingredients) == {"cheese", "tomatoes"}:
            print("Order margherita from FancyPizza")
        else:
            print(f"Order generic pizza from FancyPizza with {ingredients}")

class GourmetPizza(AbstractPizza):
    def order(self, ingredients: List[str]):
        print(f"Order pizza from GourmetPizza with {ingredients}")

def main():
    ingredients = ["cheese", "tomatoes"]
    fancy_pizza = FancyPizza()
    fancy_pizza.order(ingredients)

    gourmet_pizza = GourmetPizza()
    gourmet_pizza.order(ingredients)
Enter fullscreen mode Exit fullscreen mode

Here, FancyPizza checks if the ingredients match a Margherita pizza, while GourmetPizza simply prints the order with the provided ingredients. The key benefit is that the main() function remains decoupled from specific implementations, allowing us to easily swap or extend the pizza services without modifying the client code.

Dependency injection

What is Dependency Injection?

Now that we’ve decoupled the high-level module (main()) from the specific implementation (PizzaFan) using Dependency Inversion, let’s see how we can inject those dependencies. Dependency Injection (DI) is a technique that provides an object with its dependencies rather than letting the object create those dependencies itself. This makes the system more flexible, as dependencies can be swapped out easily, and it promotes better separation of concerns.

Step 1: Dependency Injection with Manual Assembly

In this step, we demonstrate manual dependency injection, where dependencies are passed to the client at runtime.

We’ll model this with two levels of services: Electricity Providers and Banks. The Bank class depends on an ElectricityProvider, but the specific provider (e.g., Solar or Wind) is injected at runtime.

from abc import ABC, abstractmethod

# Abstract classes for electricity providers and banks
class ElectricityProvider(ABC):
    @abstractmethod
    def pay(self, amount: float):
        pass

class Bank(ABC):
    def __init__(self, provider: ElectricityProvider):
        self.provider = provider

    @abstractmethod
    def pay_electricity(self, amount: float):
        pass

# Concrete implementations
class SolarPower(ElectricityProvider):
    def pay(self, amount: float):
        print(f"Paid {amount} to Solar Power")

class WindEnergy(ElectricityProvider):
    def pay(self, amount: float):
        print(f"Paid {amount} to Wind Energy")

class GlobalBank(Bank):
    def pay_electricity(self, amount: float):
        print("Global Bank processing payment...")
        self.provider.pay(amount)

class NationalBank(Bank):
    def pay_electricity(self, amount: float):
        print("National Bank processing payment...")
        self.provider.pay(amount)

def main():
    solar_provider = SolarPower()  # Service
    global_bank = GlobalBank(solar_provider)  # Client with injected service
    global_bank.pay_electricity(100)

    wind_provider = WindEnergy()  # Service
    national_bank = NationalBank(wind_provider)  # Client with injected service
    national_bank.pay_electricity(200)

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

In this example, the Bank class is decoupled from the specific electricity provider by using constructor injection. The client code (in main()) determines which provider to inject at runtime.

Step 2: Dependency Injection with a Dependency Service

In this step, we introduce a Dependency Service that centralizes the logic of creating and providing dependencies. This removes the responsibility of assembling dependencies from the client code, making the system easier to maintain and extend.

from abc import ABC, abstractmethod

class DependencyService:
    @staticmethod
    def get_bank() -> 'Bank':
        return GlobalBank(WindEnergy())

# Abstract classes for electricity providers and banks
class ElectricityProvider(ABC):
    @abstractmethod
    def pay(self, amount: float):
        pass

class Bank(ABC):
    def __init__(self, provider: ElectricityProvider):
        self.provider = provider

    @abstractmethod
    def pay_electricity(self, amount: float):
        pass

# Concrete implementations
class SolarPower(ElectricityProvider):
    def pay(self, amount: float):
        print(f"Paid {amount} to Solar Power")

class WindEnergy(ElectricityProvider):
    def pay(self, amount: float):
        print(f"Paid {amount} to Wind Energy")

class GlobalBank(Bank):
    def pay_electricity(self, amount: float):
        print("Global Bank processing payment...")
        self.provider.pay(amount)

class NationalBank(Bank):
    def pay_electricity(self, amount: float):
        print("National Bank processing payment...")
        self.provider.pay(amount)

def main():
    global_bank = DependencyService.get_bank()
    global_bank.pay_electricity(100)

if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Here, the DependencyService abstracts the logic for creating the Bank and its dependencies. The client (main() function) no longer needs to know how the dependencies are created, simplifying the code.

Step 3: Using a Dependency Injection Library

Finally, we use the dependency-injector library to automate dependency injection. This formalizes the process by centralizing the configuration and management of dependencies in a container.

from abc import ABC, abstractmethod
from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject

# Abstract classes
class ElectricityProvider(ABC):
    @abstractmethod
    def pay(self, amount: float):
        pass

class Bank(ABC):
    def __init__(self, provider: ElectricityProvider):
        self.provider = provider

    @abstractmethod
    def pay_electricity(self, amount: float):
        pass

# Concrete implementations
class SolarPower(ElectricityProvider):
    def pay(self, amount: float):
        print(f"Paid {amount} to Solar Power")

class WindEnergy(ElectricityProvider):
    def pay(self, amount: float):
        print(f"Paid {amount} to Wind Energy")

class GlobalBank(Bank):
    def pay_electricity(self, amount: float):
        print("Global bank processing payment...")
        self.provider.pay(amount)

class NationalBank(Bank):
    def pay_electricity(self, amount: float):
        print("National Bank processing payment...")
        self.provider.pay(amount)

# Container to manage dependencies
class Container(containers.DeclarativeContainer):
    electricity_provider = providers.Singleton(SolarPower)  # Default to SolarPower
    bank = providers.Factory(GlobalBank, provider=electricity_provider)

@inject
def main(bank: Bank = Provide[Container.bank]):
    bank.pay_electricity(150)

if __name__ == '__main__':
    container = Container()
    container.wire(modules=[__name__])
    main()
Enter fullscreen mode Exit fullscreen mode

Explanation:

  • Container: This class is a central configuration for managing dependencies. It wires together services (SolarPower), and clients (GlobalBank) through providers.
  • Providers: Providers define how dependencies are created or managed. In this example:
    • providers.Singleton: This provider ensures that a single instance of SolarPower is shared across the application.
    • providers.Factory: This provider creates new instances of Eurobank, injecting the SolarPower provider each time.
  • @inject and Provide: These decorators and classes simplify injecting dependencies into functions, in this case, injecting the Bank into the main() function.

By using dependency-injector, you streamline dependency management and make your system easier to scale.

Conclusion

By understanding and applying both the Dependency Inversion Principle and Dependency Injection, we decouple our code, making it more flexible, adaptable, and maintainable. We’ve explored three different approaches to implementing DI: manual assembly, using a centralized Dependency Service, and leveraging a DI library. Each method enhances modularity and ease of maintenance, allowing us to focus on writing scalable, testable, and extensible Python code.

Try these patterns in your own projects, and feel free to share your feedback or questions in the comments!

Top comments (0)