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:
Activate the virtual environment:
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:
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.
UserServiceis tied directly toUserRepository, andUserRepositoryis 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:
You'll see output like:
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:
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:
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:
Run the mock implementation:
You'll see:
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:
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:
You should see:
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:
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:
You should see the same output:
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:
Now refactor your app.py to define a container that manages your dependencies:
Save the file and run:
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:
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.