How to implement  Dependency Injection in Python

How to implement Dependency Injection in Python

5 min read
Edit this post
Read on DevTo
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 json
2import requests
3
4from enum import Enum
5from typing import Dict
6
7from requests.models import HTTPError
8
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] = None
19 ):
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, HttpMethod
2
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 todos
12task = 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 = client
4
5 def fetch_todos(self):
6 url = "https://jsonplaceholder.typicode.com/todos"
7 return self.client.request(HttpMethod.GET, url)
8
9# instantiate HttpClient
10client = HttpClient()
11# Injecting client instance of the client to the Task class
12task = 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, HttpMethod
2from dependency_injector import containers, providers
3
4class Task:
5 def __init__(self, client: HttpClient):
6 self.client = client
7
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=client
17 )
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.

Comments