How to implement Dependency Injection in Python
Dependency Injection is a well know Design pattern that is being used in Object-Oriented Programming (OOP). It helps to achieve loose coupling inside the component.
Before understanding what is Dependency injection, let's define what is Dependency.
Dependency - a class, component, or another library that is required to fulfill the complete functionality of a program. As an example, you may use some library-like request
. The requests library is relying on other packages for full-fledged work.
Injection - means passing some unit like function or object as an argument.
Now let's identify what is Dependency Injection.
Dependency Injection (DI) - is a design pattern that allows passing an object to a class that requires it. Usually, we inject some kind of Service a.k.a a class that contains some business logic. A dependent object is created outside the class and passed to a constructor.
Forms of DI
Dependency injection has several forms of implementation.
- Constructor injection is a common form of DI that is being used. Dependency is being passed to a constructor of a particular object.
- Method injection. Dependency is being passed to a method as an argument.
- Setter injection is appropriate for optional dependencies. Dependency is passed via the setter function.
Advantages of Dependency Injection
- Allows replacing the original object with mock objects for testing purposes;
- High cohesion, low coupling. Allows creating components more reusable and independent;
- Easier to maintain the codebase and introduce new changes;
- Classes are more extendable that rely on abstraction instead of implementation
Types of dependencies
Inheritance
Inheritance - defines an is-a relationship. Is-a relationship is considered the strongest dependency relationship between objects. Inheritance introduces high coupling between two objects that is hard to remove.
Your subclasses are using all defined methods and attributes from a superclass. In case of some changes are introduced in the superclass that leads to changes in all subclasses.
Composition
Composition - defines the has-a
relationship between objects. It allows the creation of a composed object by combining objects of other types.
Now let’s build a small API wrapper for jsonplaceholder to fetch a list of todos. First of all, we will define a HttpClient
class that will have a request method for making POST
or GET
requests. Let's see the example below.
client.py
1import json2import requests3
4from enum import Enum5from typing import Dict6
7from requests.models import HTTPError8
9class HttpMethod(Enum):10 GET = "GET"11 POST = "POST"12
13class HttpClient:14 def request(15 self,16 method: HttpMethod,17 url: str,18 data: Optional[dict] = None19 ):20 if method == HttpMethod.GET:21 response = requests.get(url=url, params=data)22 elif method == HttpMethod.POST:23 response = requests.post(url=url, data=json.dumps(data))24
25 if response.ok:26 return response.json()27
28 raise HTTPError(response.json())
Here HttpClient
is a service.
Service - is a class that contains some business logic of your application.
We will create an instance of HttpClient
inside the __init__
and call a request method of that client to make a Get request.
Now let's create task_composition.py
file that will use our HttpClient
and fetch todos.
1from client import HttpClient, HttpMethod2
3class Task:4 def __init__(self):5 self.client = HttpClient()6
7 def fetch_todos(self):8 url = "https://jsonplaceholder.typicode.com/todos"9 return self.client.request(HttpMethod.GET, url)10
11# createing Task instsance and fetching todos12task = Task()13todos = task.fetch_todos()
Our Task
class is composed of HttpClient
class. This removes high coupling between our classes and makes them more flexible.
Still, we have some hardcoded dependencies in our Task class. If at some point, we decide to make changes to the HttClient. We decided to add some arguments to the initializer, in this case, we will have to make changes to the Task class. To make our Task class more flexible, we can instantiate HttClient class from outside of our Task class and pass its instance to the init. Let’s have a look at a new example.
task.py
1class Task:2 def __init__(self, client: HttpClient):3 self.client = client4
5 def fetch_todos(self):6 url = "https://jsonplaceholder.typicode.com/todos"7 return self.client.request(HttpMethod.GET, url)8
9# instantiate HttpClient10client = HttpClient()11# Injecting client instance of the client to the Task class12task = Task(client=client)13todos = task.fetch_todos()
Now Task
class receives our HttpClient
outside of the class and can be easily tested by replacing real HttpClient
with some mock object or with another client implementation.
This example is not ideal and now we have to remember to manually create and pass our dependency every time any class needs it. To solve this problem we can use something called Dependency Injection Container.
DI Container
DI Container is an object that keeps track of dependencies. The container handles creating instances of a class and configures objects. The container uses a factory pattern for creating instances of the dependent object and allows us to inject them into our class.
From now on we don’t need to know how to create and inject dependencies, the container will take care of that for us.
DI Container is very useful when you need to provide lots of dependencies to a class and doing it manually can be a tedious task.
Implementing DI with injector library
Now we will try to modify the above code to use dependency injector library.
We will need to understand two key features: container and provider.
The provider is responsible for how an object will be accessed.
The container is a collection of our providers.
Now, let's change our existing example. We will configure and create the HttpClient
object with the dependency injector. Our Task
class stays the same, but we introduce a new Container
class to assemble the Task class.
Let’s install dependency injector
package.
pip install dependency_injector
Now let's create a Container
class
1from client import HttpClient, HttpMethod2from dependency_injector import containers, providers3
4class Task:5 def __init__(self, client: HttpClient):6 self.client = client7
8 def fetch_todos(self):9 url = "https://jsonplaceholder.typicode.com/todos"10 return self.client.request(HttpMethod.GET, url)11
12class Container(containers.DeclarativeContainer):13 client = providers.Singleton(HttpClient)14 task_factory = providers.Factory(15 Task,16 client=client17 )18
19container = Container()20task = container.task_factory()21todos = task.fetch_todos()
We have defined two providers, Singleton and Factory.
- Singleton provider - we specify our
HttpClient
(an object that needs to be injected) as Singleton so it only creates single instances of a class. - Factory provider - requires two arguments, Task class and corresponding and client argument that we create in the previous step. The Factory provider injects the dependencies whenever creating a new object.
Now we can instantiate the Task class without creating and passing HttpClient
ourselves. In such a small example, there are no advantages of using DI Container, but in a more complex example with lots of dependencies being used, you should consider using this approach.
Conclusion
I hope you found it useful. Let's summarize what we learned. Use Dependency Injection whenever you need to achieve decoupling, reusability, and testability. You are not required to use classes to use Dependency Injection. It can be used with functions as well, but the concept stays the same. You pass some dependency that must be defined outside of the function.