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.
-
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.
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 PizzaFan
class 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()
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()
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)
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()
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()
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()
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!
Source link
lol