Dependency Injection in Python

Dependency Injection in Python

ยท

5 min read

Motivation: After mindlessly creating projects and writing code that implements somethings but never made any sense from a bird's eye view for almost 11 years, it was time to learn something that would help me understand design patterns and write code that actually made sense and looked aesthetic (bringing in Instagram vibes to SidTalksTech ๐Ÿ˜). Recently came across Dependency Injection in Python at work and thought of giving it a try.

What is Dependency Injection (DI) in Python?

DI is a common design pattern used in most modern (and not so modern) programming languages. Yet it remains an underutilized functionality in Python. A lot of people speak for and against the use of DI in Python which is a topic for another day.

How to use Dependency Injection (DI) in Python?

dependency-injector (docs) is python library that provides a framework which enables you to implement DI and IoC in Python.

GETTING STARTED

Two key components of dependency-injector are

  • containers
  • providers

providers create object and inject the dependencies. Resource, Factory, Configuration, Singleton, Callable, etc. are some of the provider types and this is not an exhaustive list but we'll keep our discussion limited around these types to justify the scope of the article.

container is merely a collection of providers.

Keep the below point in mind as it will be the only mantra to live by while implementing DI:

Each provider is callable. Whenever you need an object of particular type, just call the corresponding provider as you would call a function.

Enough with the theory and now,

Let's get our hands dirty:

We'll start by importing containers and providers from dependency_injector package.

from dependency_injector import containers, providers

We'll create two classes School and Student. A Student goes to a particular School, so Student has a dependency on School.

Let's look at the attributes of these classes:

School:

  • schoolname: str - Every school has a name, obviously!
  • city: str - Where do you study? A school named <schoolname> in <city>
  • board: str - What education board does your school follow? C.B.S.E.? I.C.S.E. or State Board?

Student:

  • name: str - Again, every student has a name, obviously!
  • age: int - How old are you? I am <age> years old.
  • school: School - In which school do you study? I study at <schoolname>
  • grade: int - What grade you are in? I am in <grade>
class School:

    def __init__(self, name: str, city: str, board: str):
        self.schoolname = name
        self.city = city
        self.board = board

    def get_schoolname(self):
        print(f"school name is {self.schoolname}")


class Student:

    def __init__(self, name: str, age: int, grade: int, school: School):
        self.name = name
        self.age = age
        self.grade = grade
        self.school = school
        #super().__init__(school, city, board)

    def get_students(self):
        student_detail = f"""
            Student Name: {self.name}
            Student Age: {self.age}
            Student Grade: {self.grade}
            School: {self.school.schoolname}
            Exam Board: {self.school.board}
            City: {self.school.city}
        """

        print(student_detail)

To create dependencies we'll create a class called Container

class Container(containers.DeclarativeContainer):

    school = providers.Singleton(
        School,
        "Kendriya Vidhyala",
        "C.B.S.E.",
        "Bina"
    )

    student = providers.Factory(
        Student,
        "Siddhartha",
        28, 12,
        school
    )

Here we have created 2 providers, school and student. To initialize a factory provider, we use providers.Factory and provide a class as the first argument and other arguments are the class attributes required to initialize the class. Singleton provider works in the same way as a factory provider with an added restriction that it will only create a single object which will be memorized in the entire application lifetime.

Since many student go to a single school, we have established that relationship by making Student a Factory and School a Singleton.

Since, we have established this fact above that Student has a dependency on School, we'll now establish this dependency. If you watch carefully, after creating the school dependency will have injected it into student. Look close and observe the last argument of student provider:

school = providers.Singleton(
    School,
    "Kendriya Vidhyala",
    "C.B.S.E.",
    "Bina"
)

student = providers.Factory(
    Student,
    "Siddhartha",
    28, 12,
    school
)

This is how we create and inject dependencies. Now all we need to do is create a single container object and wire the modules together and it will take care of all the underlying object creation and their injections at suitable positions. This is how we do it:

container = Container()
container.wire(modules=[sys.modules[__name__]])

Now whenever we need to see the Student, we call the get_student() function using the student provider.

student = Container.student()
student.get_students()

Here is what the code looks in it's entirity:

import sys
from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class School:

    def __init__(self, name: str, city: str, board: str):
        self.schoolname = name
        self.city = city
        self.board = board

    def get_schoolname(self):
        print(f"school name is {self.schoolname}")

class Student():

    def __init__(self, name: str, age: int, grade: int, school: School):
        self.name = name
        self.age = age
        self.grade = grade
        self.school = school
        #super().__init__(school, city, board)

    def get_students(self):
        student_detail = f"""
            Student Name: {self.name}
            Student Age: {self.age}
            Student Grade: {self.grade}
            School: {self.school.schoolname}
            Exam Board: {self.school.board}
            City: {self.school.city}
        """

        print(student_detail)


class Container(containers.DeclarativeContainer):

    school = providers.Factory(
        School,
        "Kendriya Vidhyala",
        "C.B.S.E.",
        "Bina"
    )

    student = providers.Factory(
        Student,
        "Siddhartha",
        28, 12,
        school
    )


def main():

    student = Container.student()
    student.get_students()


if __name__ == "__main__":

    container = Container()
    container.wire(modules=[sys.modules[__name__]])
    main()

When you run the code this will be your expected output:

Student Name: Siddhartha
Student Age: 28
Student Grade: 12
School: Kendriya Vidhyala
Exam Board: C.B.S.E.

To be honest, this example can easily be implemented using inheritence. But when your code bigger and bigger with tens and hundreds of classes, keeping a track of all the objects and the their position in inheritence tree is extremely difficult. This is where DI comes handly.

It looks a bit overwhelming at the start but once you understand it, it makes your code extremly flexible to change and test.

I'll be writing a detailed article with advanced use of DI in a real-world use-case somewhere in near future. So if you found this article helpful, your feedback would be genuinely appreciated so that I can avoid similar pitfalls in upcoming articles

ย