Back to Scaling Python Applications guides

Dependency Injection in Python

Stanley Ulili
Updated on April 29, 2025

Python gives you several ways to use dependency injection, a design pattern that makes your code easier to test and maintain.

Although Python doesn’t have built-in support for it, the language is flexible enough that you can implement dependency injection easily, either with simple techniques or by using libraries.

In this article, you’ll learn how to add dependency injection to your Python projects, starting with basic examples and moving up to using full frameworks.

Prerequisites

Before you continue, make sure you have Python 3.9 or higher installed on your computer.
This guide also assumes you already understand basic Python and know how object-oriented programming works.

Step 1 — Understanding dependency injection fundamentals

For the best learning experience, set up a fresh Python project to experiment directly with the concepts introduced in this tutorial.

Begin by creating a new directory and setting up a virtual environment:

 
mkdir python-dependency-injection && cd python-dependency-injection
 
python3 -m venv venv

Activate the virtual environment:

 
source venv/bin/activate

Let's start with a real-world example that demonstrates the problems dependency injection solves. Create a file named app.py with the following content:

app.py
class DatabaseConnection:
    def __init__(self):
        print("Connecting to production database...")
        # In a real application, this would establish a database connection

    def execute_query(self, query):
        print(f"Executing query: {query}")
        return ["result1", "result2"]  # Simulated query results

class UserRepository:
    def __init__(self):
        # Hard-coded dependency
        self.database = DatabaseConnection()

    def get_users(self):
        return self.database.execute_query("SELECT * FROM users")

class UserService:
    def __init__(self):
        # Hard-coded dependency
        self.repository = UserRepository()

    def get_all_users(self):
        return self.repository.get_users()

if __name__ == "__main__":
    user_service = UserService()
    users = user_service.get_all_users()
    print(f"Retrieved users: {users}")

At first, everything looks fine. The application connects to the database, runs a query, and prints the results. But this setup has hidden problems:

  • Each class creates its dependencies instead of receiving them from the outside.
  • UserService is tied directly to UserRepository, and UserRepository is tied to DatabaseConnection. Testing becomes difficult because it is not easy to replace the database with a mock or fake version. Making changes, such as switching to a different database or adding caching, would require modifying several classes.
  • The code is tightly coupled, which makes it harder to maintain and extend as your project grows.

Run your script with the following command:

 
python app.py

You'll see output like:

Output
Connecting to production database...
Executing query: SELECT * FROM users
Retrieved users: ['result1', 'result2']

While this works for a simple application, it becomes problematic as your codebase grows. Dependency injection solves this by allowing you to build flexible, testable systems where classes receive their dependencies instead of creating them.

Step 2 — Implementing constructor injection

The most common form of dependency injection is constructor injection, where dependencies are provided through a class's constructor.

Diagram: Left side shows tightly coupled classes creating their own dependencies. Right side shows loosely coupled classes receiving dependencies from outside.

Let's refactor our example to use this approach:

app.py
class DatabaseConnection:
    def __init__(self):
        print("Connecting to production database...")
        # In a real application, this would establish a database connection

    def execute_query(self, query):
        print(f"Executing query: {query}")
        return ["result1", "result2"]  # Simulated query results

class UserRepository:
def __init__(self, database_connection):
# Dependency injected via constructor
self.database = database_connection
def get_users(self): return self.database.execute_query("SELECT * FROM users") class UserService:
def __init__(self, user_repository):
# Dependency injected via constructor
self.repository = user_repository
def get_all_users(self): return self.repository.get_users() if __name__ == "__main__":
# Manual wiring of dependencies
database = DatabaseConnection()
repository = UserRepository(database)
service = UserService(repository)
users = service.get_all_users()
print(f"Retrieved users: {users}")

In the updated code, we changed how dependencies are handled. Instead of each class creating its dependencies, we now pass them in through the constructor. UserRepository takes a database_connection when it is created, and UserService takes a user_repository.

We also introduced manual wiring at the application's entry point. In the __main__ block, we first create a DatabaseConnection, then pass it to the UserRepository, and finally pass the repository to the UserService. This pattern clearly indicates where and how objects are created and connected.

Run this updated script:

 
python app.py

The output remains the same, but you've gained tremendous flexibility in how the application is constructed. Classes no longer decide which specific implementations to use; they simply work with what they are given.

This separation makes testing much easier because you can now pass in mock objects or alternative implementations without touching the internal logic of the classes.

It also improves maintainability. If you ever need to replace the database with a different system, add caching, or log queries, you only need to adjust the wiring code, not the classes themselves.

To illustrate this flexibility, let's create mock implementations for testing:

app.py
class DatabaseConnection:
    ...

class UserRepository:
    ...

class UserService:
    ...

class MockDatabaseConnection:
def __init__(self):
print("Creating mock database connection...")
def execute_query(self, query):
print(f"Mock executing query: {query}")
return ["mock_user1", "mock_user2"] # Test data
if __name__ == "__main__":
# Wiring with mock implementation
mock_database = MockDatabaseConnection()
repository = UserRepository(mock_database)
service = UserService(repository) users = service.get_all_users() print(f"Retrieved users: {users}")

Run the mock implementation:

 
python app.py

You'll see:

Output
Creating mock database connection...
Mock executing query: SELECT * FROM users
Retrieved users: ['mock_user1', 'mock_user2']

Notice how you replaced the database implementation without changing any business logic. This is the power of dependency injection: components no longer create their own dependencies, which makes them easier to test and more flexible.

Step 3 — Adding interfaces with abstract base classes

Right now, your classes depend directly on specific implementations.
Even though you can swap in a mock, the classes still know too much about the details.
To make the system even more flexible, you can define formal interfaces that describe what behavior is expected, without tying classes to specific implementations.

Class diagram showing UserService depending on UserRepositoryInterface, with interchangeable implementations (SQLUserRepository and InMemoryUserRepository) and a DatabaseInterface with swappable implementations.

Before making the next changes, remove or comment out the MockDatabaseConnection class you created earlier for testing.
We are now moving toward a production-style structure.

Update your app.py as follows:

app.py
from abc import ABC, abstractmethod
from typing import List
# Define a database interface
class DatabaseInterface(ABC):
@abstractmethod
def execute_query(self, query: str) -> List[str]:
pass
# Define a user repository interface
class UserRepositoryInterface(ABC):
@abstractmethod
def get_users(self) -> List[str]:
pass
# Concrete database implementation
class ProductionDatabase(DatabaseInterface):
def __init__(self): print("Connecting to production database...") def execute_query(self, query): print(f"Executing query: {query}") return ["result1", "result2"] # Concrete repository implementation
class SQLUserRepository(UserRepositoryInterface):
def __init__(self, database: DatabaseInterface):
self.database = database
def get_users(self): return self.database.execute_query("SELECT * FROM users") class UserService:
def __init__(self, user_repository: UserRepositoryInterface):
self.repository = user_repository
def get_all_users(self): return self.repository.get_users() if __name__ == "__main__": # Manual wiring of dependencies
database = ProductionDatabase()
repository = SQLUserRepository(database)
service = UserService(repository)
users = service.get_all_users() print(f"Retrieved users: {users}")

In this code, you introduced two interfaces: DatabaseInterface and UserRepositoryInterface.

Now UserRepository and UserService no longer care about specific implementations. They only care that the dependencies follow the right structure.

You also renamed the classes slightly.
ProductionDatabase makes it clear that this is just one database implementation, and SQLUserRepository suggests that different kinds of repositories could be added later without changing the service code.

This makes the design cleaner, more scalable, and even easier to test.

Save the file and run:

 
python app.py

You should see:

Output
Connecting to production database...
Executing query: SELECT * FROM users
Retrieved users: ['result1', 'result2']

Using interfaces keeps your classes focused, flexible, and easier to extend as your application grows.

Step 4 — Automating dependency wiring with a simple factory function

So far, you have been manually creating and connecting objects inside the __main__ block.
This works, but as your application grows, manual wiring can quickly become messy and repetitive.
A cleaner approach is to move the creation and wiring of dependencies into a dedicated function, often referred to as a factory function.

Let's refactor your app.py to introduce a simple factory:

app.py
from abc import ABC, abstractmethod
from typing import List

...
class UserService:
    def __init__(self, user_repository: UserRepositoryInterface):
        self.repository = user_repository

    def get_all_users(self):
        return self.repository.get_users()

# New factory function to wire everything together
def create_user_service() -> UserService:
database = ProductionDatabase()
repository = SQLUserRepository(database)
service = UserService(repository)
return service
if __name__ == "__main__":
user_service = create_user_service()
users = user_service.get_all_users()
print(f"Retrieved users: {users}")

In this code, you moved the object creation and wiring into a dedicated create_user_service() factory function.
Instead of manually assembling the dependencies in the __main__ block, you now call a single function that returns a fully wired UserService instance.

This approach keeps the main part of your program clean and focused on running the application, not building objects.
It also makes it easier to swap out parts later. For example, you could create a separate factory that wires up mock implementations for testing purposes without altering your business logic.

Save the file and run:

 
python app.py

You should see the same output:

Output
Connecting to production database...
Executing query: SELECT * FROM users
Retrieved users: ['result1', 'result2']

Moving the object creation logic into a factory function keeps your __main__ block clean and organized.
It also makes it easier to swap implementations later. For example, you could create another factory for testing wires in mock dependencies instead of production ones.

Step 5 — Managing dependencies automatically with a DI library

Using a factory function keeps your code cleaner, but as your project grows, you might still find yourself writing a lot of wiring code manually.

To automate this even further, you can use a lightweight dependency injection (DI) library like dependency-injector.

Let's update your project to use it.

First, install the library:

 
pip install dependency-injector

Now refactor your app.py to define a container that manages your dependencies:

app.py
from abc import ABC, abstractmethod
from typing import List
from dependency_injector import containers, providers
... class UserService: def __init__(self, user_repository: UserRepositoryInterface): self.repository = user_repository def get_all_users(self): return self.repository.get_all_users() # New container to manage dependencies
class Container(containers.DeclarativeContainer):
database = providers.Singleton(ProductionDatabase)
user_repository = providers.Factory(SQLUserRepository, database=database)
user_service = providers.Factory(UserService, user_repository=user_repository)
if __name__ == "__main__":
container = Container()
user_service = container.user_service()
users = user_service.get_all_users()
print(f"Retrieved users: {users}")

Save the file and run:

 
python app.py

In this code, you use the dependency-injector library to manage your dependencies automatically.
The Container class defines how objects are created and connected. ProductionDatabase is a singleton, while SQLUserRepository and UserService are factories that receive their dependencies from the container.

In the __main__ block, you simply ask the container for a UserService, and it builds everything for you.

You should see:

Output
Connecting to production database...
Executing query: SELECT * FROM users
Retrieved users: ['result1', 'result2']

With dependency-injector, you no longer need to create or wire your objects manually.
The container knows how to build your service and all its dependencies automatically.

You also get fine control over scopes (singleton vs factory), configuration, and easier swapping of implementations later if needed.

Final thoughts

In this tutorial, you saw how to build clean, flexible Python applications using dependency injection. You started with hard-coded dependencies, refactored to constructor injection, introduced interfaces for better separation, and used factories to keep wiring organized. Finally, you automated everything with a proper dependency injection container.

This setup makes your code easier to test, extend, and maintain as your project grows. If you want to explore further, consider examining additional features in dependency-injector, such as configuration management, advanced scopes, and automatic wiring.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Next article
Working With JSON Data in Python
Learn how to efficiently handle JSON data in Python with the built-in `json` module. This guide covers serializing Python objects to JSON, deserializing JSON back to Python dictionaries, formatting JSON for readability, and validating JSON data using JSON Schema.
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github