Back to Posts
Apple being injected with syringes, depicting Python dependency injection best practices in a conceptual manner.

Best Practices for Python Dependency Injection

By Alyce Osbourne

In another post, I discussed the distinction between composition and inheritance and clarified that composition involves dependency injection but is not equivalent to it. In this blog post, I’ll delve deeper into the concept of dependency injection (DI), exploring its practical applications and nuances in Python programming.

What is dependency injection?

Dependency injection is a software design pattern that enables a program to eliminate hardcoded dependencies and provides the flexibility to replace them with other implementations. This approach differs from the traditional object-oriented programming (OOP) paradigm, in which classes internally create the objects they depend on.

Why do we use dependency injection?

Dependency injection plays a crucial role in reducing coupling in software, which is vital for creating scalable and adaptable applications. By decoupling the creation of dependent objects from the class that uses them, we achieve a more modular and cohesive design. This approach is particularly beneficial in library development, allowing for greater flexibility in implementing user-specific functionalities. Moreover, DI enables testable, maintainable, and flexible software.

Rationale

Understanding the rationale of dependency injection can further demonstrate its practical benefits. DI aligns closely with the object-oriented design principle of “Inversion of Control” (IoC), which shifts control of object creation and binding from the class itself to an external entity, often a framework. This inversion leads to more flexible and testable code.

For the rationale explained in 1 minute, check out my video: Dependency Injection Explained In One Minute.

Where might we use dependency injection?

Dependency injection finds application across numerous domains in software development. It’s instrumental in building complex systems where components need to be easily replaceable or upgradable, such as web applications, cloud services, and large-scale data processing systems. Additionally, DI is a key element in several design patterns, including advanced ones like the Abstract Factory, Builder, and Prototype patterns, in addition to the basic patterns like decorators or factories.

How do we use dependency injection?

Dependency injection can be implemented in various ways, with constructor and method injection being the most prevalent. These techniques allow developers to create code structures that are flexible and testable.

Constructor injection

Constructor injection involves providing dependencies through a class’s constructor. It’s a straightforward method that ensures a class has all its necessary dependencies before use. This form of injection is widely used in modern frameworks and libraries due to its simplicity and effectiveness in enforcing a clear dependency contract.

```python
class Database:
    def query(self, query: str):
        # execute query
        pass

class Api:
    def __init__(self, database: Database):
        self.database = database

    def get_user(self, user_id: int):
        return User(*self.database.query(f"SELECT * FROM users WHERE id={user_id}"))

class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email


## Method injection


Method injection involves supplying dependencies directly through method parameters. This technique is particularly useful for dependencies that are only needed in specific operations rather than throughout the entire lifecycle of an object. This method tends to work best when the class acts as the configuration for the process but relies on external dependencies to perform the actual work.


```javascript

class Database:
    def query(self, query: str):
        # execute query
        pass

class Api:
    def get_user(self, user_id: int, database: Database):
        return User(*database.query(f"SELECT * FROM users WHERE id={user_id}"))

class User:
    def __init__(self, name: str, email: str):
        self.name = name
        self.email = email

Notice how both the constructor and method forms of injection are are similar but follow different semantics. In the constructor example, the database is a dependency of the API, which means that any data used from the API will always be from the same database. In the second example, the database is a dependency of the method, which means that the database can be different for each call to the method. This is a subtle but important distinction, and one that can have different implications depending on the use case. For the former, we can be more confident that there is a 1–1 relationship between our data and the database, whereas the latter allows us to pull data from multiple sources. One or the other may be preferable depending on the use case, but both are valid and useful.

Dependency injection in testing

Dependency injection is particularly valuable in testing, as it enables developers to replace real dependencies with mock or stub objects. This practice is essential for unit testing, as it ensures that tests are not affected by external factors like database connections or network access. It promotes the development of reliable and independent test cases, which are essential for robust software development.

import pytest
from unittest.mock import Mock

from api import Api, User, Database

def test_get_user():
    database = Mock(Database)
    database.query.return_value = {"name": "Arjan", "email": "example@arjancodes.com"}

    api = Api(database)

    user = api.get_user(1)

    assert isinstance(user, User)

    assert user.name == "Arjan"
    assert user.email == "example@arjancodes.com"

Advanced concepts in dependency injection

To further enhance the use of dependency injection, it’s worth exploring advanced concepts such as lifecycle management and scope. Understanding these concepts can help create more sophisticated and efficient applications, especially when dealing with complex dependency graphs or when optimizing performance.

Lifecycle management

Managing the lifecycle of dependencies is crucial in DI. Different objects may require different lifecycle management strategies, such as singleton, prototype, or request-scoped lifecycles. Selecting the appropriate lifecycle strategy is key to ensuring efficient resource utilization and avoiding memory leaks. Making sure to manage the lifecycle of dependencies is also essential for testing, as it allows developers to create mock objects that are only used for the duration of a test.

Scope and context

In more complex applications, particularly those involving frameworks like Django or Flask, the scope of a dependency, such as application-wide, request-specific, or session-specific, plays a significant role in how dependencies should be injected and managed. Understanding the scope of a dependency is essential for creating efficient and scalable applications. Managing scope is also critical for creating secure applications, as it helps prevent data leakage and other security vulnerabilities.

Final thoughts

Dependency injection is a powerful technique in the Python developer’s toolkit. It’s essential for creating software that is modular, testable, and maintainable. Whether you are building a small utility library or a large-scale enterprise application, understanding and effectively utilizing dependency injection can significantly enhance the quality and robustness of your software.

Improve your code with my 3-part code diagnosis framework

Watch my free 30 minutes code diagnosis workshop on how to quickly detect problems in your code and review your code more effectively.

When you sign up, you'll get an email from me regularly with additional free content. You can unsubscribe at any time.

Recent posts