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:
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 toUserRepository
, andUserRepository
is tied toDatabaseConnection
. 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:
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.
Let's refactor our example to use this approach:
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:
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:
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.
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:
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:
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:
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:
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:
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:
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.
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
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.comor submit a pull request and help us build better products for everyone.
See the full list of amazing projects on github