Skip to main content

Python Dependency Injection: Build Modular and Testable Code

Learn how to make your code modular, testable, and maintainable by understanding and implementing various Python dependency injection frameworks.
Jul 24, 2025  · 14 min read

Dependency injection is a design pattern where objects or services (we mean dependencies here) are provided to a class from the outside, instead of the class creating them internally. This decouples components.

Using dependency injection in Python has many benefits, such as:

  • Modularity: Your code is split into smaller, reusable parts.
  • Testability: It is easier to test your code because you can swap out the real part with mock ones.
  • Maintainability: You can update or modify parts of your code without breaking the rest of the system. 

In this tutorial, I will demonstrate how to use dependency injection in Python through simple, practical examples.

Understanding Dependency Injection in Python

To understand how to implement dependency injection, it's essential that you grasp the key principles that govern it.

Dependency injection is based on something called the principle of Inversion of Control (IoC), which means that instead of a class creating and managing its dependencies, it receives them from an external source. This helps to focus on a single task and makes your code clean.

Injecting dependencies in Python includes these common way,s although there are others:

  • Constructor injection: Pass dependencies through the class constructor.
  • Setter injection: Set dependencies using a setter method after the object is created.
  • Method injection: Pass dependencies directly to the method that needs them.

Why Use Dependency Injection in Python?

As a developer, understanding and implementing dependency injection can make a lot of difference in your code design. Let’s look at some benefits of dependency injection.

Modularity, resusability, and flexibility

Dependency injection enables you to break down your code into smaller, more focused components, allowing for greater flexibility and modularity. 

Each part or component handles a specific task and relies on external dependencies, making it easier to reuse in different parts of your or someone else’s application

For example, if you have a class that sends messages. Instead of building an email or SMS logic directly into your service, you can inject an email or sms sender class. 

Now, your class that sends messages does not care how the message is sent, but just uses the sender provided to it. This implies that you can use your messaging sender with different email or sms senders. 

Maintainability and testability

When using dependency injection, you can easily replace real dependencies with mock data during testing. This makes it simpler to write unit tests without relying on APIs or databases. 

Suppose, as a thinking exercise, that you have a class that saves data to a database. For testing purposes, you can inject a mock database instead of a real one to avoid coupling your test to the production database.

Dependency injection enables you to modify specific parts of your code without breaking the entire system. This is particularly handy in situations where you need to switch payment providers in an application, such as from Stripe to PayPal, without modifying the rest of your code.

Inversion of control and swappable dependencies

The idea of of inversion of control is this: dependency injection will shift the responsibility of creating and managing dependencies to an external source. 

This implies that you can easily swap one implementation for another; for example, if you have a ReportGenerator class that formats and exports reports. 

Instead of the class deciding on the format to export, you could inject an exporter class, such as PDFExporter, ExcelExporter, or CSVExporter, into it.

The ReportGenerator class does not care how the export happens, so to change a format, you just swap the exporter you inject, without modifying the ReportGenerator at all. 

Implementing Dependency Injection in Python

Now that you have a clear glimpse of what dependency injection is and why it’s useful, let’s look at how you can implement it in Python. Let’s start with the simplest method: manual dependency injection.

Manual dependency injection

In manual dependency injection, you create dependencies yourself and pass them into the class or function that needs them. 

This method works well for small applications, but as your application scales, you need to use a dependency injection framework to avoid errors.

Here is an example of code without dependency injection

class EmailService:
    def send_email(self, message):
        print(f"Sending email: {message}")


class UserNotifier:
    def __init__(self):
        self.email_service = EmailService()  # Creates its own dependency

    def notify(self, message):
        self.email_service.send_email(message)


notifier = UserNotifier()
notifier.notify("Welcome!")

In the above example, UserNotifier is tightly coupled to EmailService. You can’t easily swap or mock EmailService for testing. 

Here is another version, but with manual dependency injection.

class EmailService:
    def send_email(self, message):
        print(f"Sending email: {message}")


class UserNotifier:
    def __init__(self, email_service):
        self.email_service = email_service  # Dependency is injected

    def notify(self, message):
        self.email_service.send_email(message)


email_service = EmailService()
notifier = UserNotifier(email_service)
notifier.notify("Welcome!")

The version above is flexible and allows you to inject various messaging services; you can also replace EmailService with a mock in tests.

Using the dependency-injector framework

As your application grows, it becomes complex and messy to manage dependencies. 

The dependency-injector framework provides a structured approach.

Container-provider architecture

The dependency-injector framework works based on a container-provider architecture. 

  • Container: This is the central registry for your application dependencies.
  • Providers: This defines how your dependencies are created.
  • Configuration: This allows you to modify settings and inject them into objects. 
  • Overriding: Let’s you replace dependencies without changing application code, especially during testing. 
from dependency_injector import containers, providers


# Services
class EmailService:
    def send_email(self, message):
        print(f"Sending email: {message}")


class UserNotifier:
    def __init__(self, email_service):
        self.email_service = email_service

    def notify(self, message):
        self.email_service.send_email(message)


# Container
class Container(containers.DeclarativeContainer):
    email_service = providers.Singleton(EmailService)
    user_notifier = providers.Factory(UserNotifier, email_service=email_service)


# Usage
container = Container()
notifier = container.user_notifier()
notifier.notify("Hello!")

In the example above:

  • Container defines how instances are created

  • Singleton ensures only one instance of EmailService exists

  • Factory creates a new UserNotifier with the EmailService injected automatically.

Wiring mechanism

You can use decorators like @inject to reduce the need for manual dependency passing. 

Here is another version of the previous example:

from dependency_injector import containers, providers


# Define services
class EmailService:
    def send_email(self, message):
        print(f"Sending email: {message}")


class UserNotifier:

    def __init__(self, email_service: EmailService):
        self.email_service = email_service

    def notify(self, message):
        self.email_service.send_email(message)


# Create container
class Container(containers.DeclarativeContainer):
    email_service = providers.Singleton(EmailService)
    user_notifier = providers.Factory(UserNotifier, email_service=email_service)


from dependency_injector.wiring import inject, Provide


@inject
def main(notifier: UserNotifier = Provide[Container.user_notifier]):
    notifier.notify("Hello!")


if __name__ == "__main__":
    container = Container()
    container.wire(modules=[__name__])  # This wires the current module
    main()

In the current version;

  • Without manually adding the dependency, the @inject decorator automatically injects the function or class dependency.

  • The Provide class tells the dependency-injector which dependency to inject.

This approach eliminates the need for manual wiring dependencies from the container. 

It’s helpful in larger applications where services may depend on multiple components (hence this need to initialize them automatically).

Most modern Python frameworks make it easy to work with dependency injection, allowing you to write cleaner and more testable code. 

In this section, you will learn how dependency injection is applied in Flask, Django, and FastAPI.

Dependency injection in Flask

Flask does not have built-in support for dependency injection, but you can implement it using the dependency-injector library through these following steps.

Step 1: Define your services

class GreetingService:
    def get_greeting(self, name):
        return f"Hello, {name}!"

Step 2: Set up your dependency injection container

from dependency_injector import containers, providers

class Container(containers.DeclarativeContainer):
    greeting_service = providers.Factory(GreetingService)

Step 3: Create the Flask application and inject dependencies.

from flask import Flask, request
from dependency_injector.wiring import inject, Provide

container = Container()

app = Flask(__name__)

@app.route("/greet")
@inject
def greet(greeting_service: GreetingService = Provide[Container.greeting_service]):
    name = request.args.get("name", "World")
    return greeting_service.get_greeting(name)

container.wire(modules=[__name__])

if __name__ == "__main__":
    app.run(debug=True)

From the code above:

  • The function, name = request.args.get(“name”, “World”) extracts the name from the query string, and defaults to ”World” if not provided. 

  • return greeting_service.get_greeting(name) calls the method on the injected service and returns a greeting message. 

  • container.wire(modules=[__name__]) tells the dependency injector to scan this module for @inject decorators and Provide[...] markers to connect them to the actual providers. 

Step 4: Test the application

Run the application on your terminal using the command python <name_of_file>.py, and visit the URL http://localhost:5000/greet?name=Jacob

You should see the following.

Image showing the url of a running Flask appliction

Dependency injection in Django

Just like Flask, Django does not offer built-in dependency injection. But, you can use its class-based views, middleware, and app structure to integrate Dependency injection manually or using the dependency-injector library. 

Here is a step-by-step procedure for implementing dependency injection in Django. 

Step 1: Define your service

Create a simple service containing your business logic in a new file services.py under your app folder. 

# myapp/services.py
class GreetingService:
    def get_greeting(self, name):
        return f"Hello, {name}!"

Step 2: Set up the dependency injector container

Inside your application directory, create a container using the dependency-injector library inside a new file containers.py.

# myapp/containers.py
from dependency_injector import containers, providers
from .services import GreetingService

class Container(containers.DeclarativeContainer):
    greeting_service = providers.Factory(GreetingService)

Step 3: Create a View with injected dependencies

Use the @inject decorator in your view to receive dependencies in views.py.

# myapp/views.py
from django.http import HttpResponse
from dependency_injector.wiring import inject, Provide
from .containers import Container
from .services import GreetingService

@inject
def greet_view(
    request, greeting_service: GreetingService = Provide[Container.greeting_service]
):
    name = request.GET.get("name", "World")
    message = greeting_service.get_greeting(name)
    return HttpResponse(message)

Step 4: Update urls.py

Under your project folder, go to the urls.py file and hook your dependency injector into Django’s URL routing system.

from django.contrib import admin
from django.urls import path
from myapp.views import greet_view

urlpatterns = [
    path("admin/", admin.site.urls),
    path("greet/", greet_view),
]

Step 5: Wire the container in apps.py 

Go to apps.py and wire the container to the application. 

 # myapp/apps.py
from django.apps import AppConfig
from .containers import Container

class MyAppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "myapp"

    def ready(self):
        container = Container()
        container.wire(modules=["myapp.views"])

Then go to your app config in INSTALLED_APPS and add your application.

# settings.py
INSTALLED_APPS = [
    'myapp.apps.MyAppConfig',
    ...
]

Step 6: Test the View

Start your Django server and access the view through the URL http://localhost:8000/greet/?name=Django

You should see the following.

Image showing the Django application running.

Dependency Injection in FastAPI

FastAPI has built-in support for dependency injection through the special marker Depends() that injects whatever a function returns. 

Here is a step-by-step guide on how to go about FastAPI dependency injection.

Step 1: Define a service

class GreetingService:
    def get_greeting(self, name: str) -> str:
        return f"Hello, {name}!"

Step 2: Create the dependency function

def get_greeting_service():
    return GreetingService()

Step 3: Set up the FastAPI app and use Dependency Injection

app = FastAPI()

@app.get("/greet")
def greet(
    name: str = "World", service: GreetingService = Depends(get_greeting_service)
):
    return {"message": service.get_greeting(name)}

Step 4: Run and test the application

Start your server using the command uvicorn main:app –reload and visit the URL http://localhost:8000/greet?name=FastAPI. You should see the following response.

Image showing a running FastAPI application.

Comparing Python Dependency Injector Frameworks

There are various Python Dependency frameworks, each with its own strengths, syntax, and use cases. Choosing the proper framework depends on the size and structure of your project. 

Here is a comparison of some common Python dependency injection frameworks.

Framework

Best For

Strengths

Limitations

dependency-injector

Complex applications in production use

Full-featured, fast, and configurable

Verbose

injector

Simple and suitable for minimal applications

Clean syntax

Less advanced than others.

pinject

Advanced binding logic

Auto-wiring

Slower due to introspection

punq

Simple applications

Minimal and fast

Limited features compared to others

FastAPI Depends()

FastAPI applications

Built-in support for async, and is also testable

Not reusable outside FastAPI

Flask-injector

Flask applications

Easy integration with Flask applications

Depends on injector

django-injector

Django projects

Provides dependency injection for views and middleware

Depends on injector

Advanced Implementation Patterns

As your application code base becomes large, dependency injection becomes challenging to maintain. 

You can use advanced dependency injection to handle tasks such as request-scoped resources, cleanup, and separation of concerns. 

Scoped dependencies

Scoped dependencies enable you to control how long a dependency persists, for example, when you want a new object per request, a shared object across the app's lifetime, or automatic cleanup of resources such as database connections. 

Most dependency injector frameworks support scopes like:

  • Singleton: One instance for the entire app.
  • Factory: A new instance every time it is injected.
  • Thread-local or request: A single instance per thread/request.
  • Resource: Managed with setup and teardown logic.

The following is an example of scoped dependencies with nested containers using dependency-injector.

from dependency_injector import containers, providers, resources

# A simple resource that needs setup and cleanup
class MyResource:
    def __enter__(self):
        print("Setting up the resource")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        print("Cleaning up the resource")

# Create a container to manage the resource
class MyContainer(containers.DeclarativeContainer):
    resource = providers.Resource(MyResource)

# Using the resource with a context manager
container = MyContainer()

with container.resource() as r:
    print("Using the resource")

In the example above, MyResource mimics a class that requires setup and teardown, such as opening/closing a file or database.

The container provides the resource using providers.Resource, and when you use with container.resource(), it enters and exits the context correctly.

This pattern ensures that resources, such as file handles, database connections, or network sessions, are managed cleanly.

Imagine an API endpoint that needs a database session for the current request or a logger with request-specific context. 

With scoped dependencies, the framework creates a request-scoped container when the request begins resolving all dependencies from that scope. 

When the request ends, any necessary cleanup, such as closing database sessions, occurs automatically. This ensures that states are not shared across requests and avoids resource leaks.

Asynchronous injection

To handle I/O bound tasks efficiently, you need asynchronous execution. Dependency injection systems in frameworks like FastAPI and dependency-injector, offer asynchronous injection using async/await.

Asynchronous injection refers to injecting dependencies that are async def functions or managing async resources like async database connections, external APIs, or background tasks. 

Rather than resolving them synchronously, the dependency injection system waits for them to be resolved before injecting. 

The following is an example of an async dependency in FastAPI:

from fastapi import FastAPI, Depends

app = FastAPI()

# Async dependency
async def get_user():
    # Simulate async I/O
    await some_async_db_query()
    return {"name": "Philip Jones"}

@app.get("/profile")
async def read_profile(user: dict = Depends(get_user)):
    return {"user": user}

FastAPI automatically handles the async resolution of get_user and injects the result.

dependency-injector also supports async lifecycle management using AsyncResource providers.

from dependency_injector import containers, providers, resources

class AsyncDB:
    async def connect(self):
        print("Connected")
        return self

    async def disconnect(self):
        print("Disconnected")

class AsyncDBResource(resources.AsyncResource):
    async def init(self) -> AsyncDB:
        db = AsyncDB()
        return await db.connect()

    async def shutdown(self, db: AsyncDB):
        await db.disconnect()

class Container(containers.DeclarativeContainer):
    db = providers.Resource(AsyncDBResource)

# Usage
async def main():
    container = Container()
    await container.init_resources()

    db = await container.db()  # Get the AsyncDB instance

    # Use db here...

    await container.shutdown_resources()

In the code above:

  • resources.AsyncResource is used for async setup/teardown logic.

  • await container.init_resources() initializes all resources and calls init.

  • await container.shutdown_resources() cleans up all resources.

  • await container.db() gets the actual resources instance, which is AsyncDB.

Using async dependency injection helps keep your application performant by avoiding synchronous I/O in asynchronous routes, and eliminates the need to manually await dependencies in your logic. 

Security Implications

While dependency injection improves modularity and testability, it can introduce potential security risks if not managed carefully.

One significant risk is dependency confusion, where malicious packages mimic legitimate ones. For example, attackers have uploaded packages to PyPI with names used in internal enterprise systems to trick apps into using the malicious version.

To stay secure, ensure that you never inject untrusted or user-controlled components and validate configuration sources, such as environment variables, before they are injected.

Use requirements.txt or poetry.lock files to lock exact versions, and automate security scanning on dependency changes. Additionally, ensure that you regularly audit your dependencies for vulnerabilities.

Centralizing control of your dependencies is also essential, as it ensures consistency across your applications and facilitates the application of security patches or version updates more easily. It helps avoid "shadow dependencies" injected in different ways across the codebase. 

Several tools can detect risks and improve your security posture:

  • Snyk: Scans for known vulnerabilities in Python packages and suggests fixes.
  • Xygeni: Offers protection for CI/CD pipelines.
  • Safety: Checks for insecure packages and code vulnerabilities.
  • Bandit: A Static code analyzer for Python focused on security issues.

Real-World Applications

Dependency injection is not just a theoretical pattern; it is used in various production systems to manage complexity, improve flexibility, and streamline testing. 

Web service configuration

In modern web applications, dependency injection plays a crucial role in managing standard services, such as authentication, logging, and database access. 

Frameworks like FastAPI play an essential role in resolving routes and dependencies.

The example below illustrates a centralized authentication implementation in FastAPI.

from fastapi import Depends, HTTPException

def get_current_user(token: str = Depends(oauth2_scheme)):
    user = verify_token(token)
    if not user:
        raise HTTPException(status_code=401)
    return user

@app.get("/protected")
def protected_route(user: dict = Depends(get_current_user)):
    return {"message": f"Hello {user['name']}"}

In the code above, get_current_user() is injected as a route dependency, but the logic for checking the token is centralized and reusable.

You can also do the same for database access, such that database sessions are automatically managed and cleaned. 

Here is an example of how you can use dependency injection to centralize database access.

from sqlalchemy.ext.asyncio import AsyncSession

async def get_db_session() -> AsyncSession:
    async with async_session() as session:
        yield session

@app.get("/items")
async def read_items(db: AsyncSession = Depends(get_db_session)):
    return await db.execute("SELECT * FROM items")

In the code above, the get_db_session is a dependency provider. When the read_items route is called, FastAPI automatically injects the db parameter, allowing the route to access the database without manually managing session setup or teardown. 

Test-driven development

Dependency injection makes it easy for you to change the dependencies components receive, which is important when testing. 

You can replace real services with mocks during testing, making your code more modular and easier to test. 

FastAPI and dependency-injector support overriding dependencies at test time. 

Here is an example in FastAPI.

from fastapi.testclient import TestClient
from main import app, get_db_session

def override_db():
    return TestDBSession()

app.dependency_overrides[get_db_session] = override_db

client = TestClient(app)
response = client.get("/items")
assert response.status_code == 200

In the code example above, get_db_session is overridden with a mock database during tests; hence, there is no need to patch or alter the production code.

Same also in depedency-injector, you can override your real database with a mock database for testing purposes, like in the example below.

from containers import Container

def test_service_behavior():
    container = Container()
    container.db.override(providers.Factory(TestDB))

    service = container.service()
    assert service.get_data() == "mocked"

The override() method replaces the real dependency with a mock, which is clean, controlled, and reversible. 

Here are some strategies you should use for dependency overrides:

  • Constructor injection: Ensure you pass mocks directly into the constructor.
  • Framework overrides: Use built-in override tools such as FastAPI.dependency_overrides in FastAPI or dependency_injector.override() in dependency-injector.
  • Fixtures: Use testing frameworks like pytest to create reusable mocks and inject them via fixtures.

Best Practices and Anti-Patterns

Although dependency injection is intended to make code modular and easy to test, it can lead to bugs or create complexity in the code. 

Here are some best practices you should adhere to when creating dependencies:

  • Program to interfaces, not implementations: Define your dependencies using abstract base classes or interfaces rather than complex implementations. This makes your code easier to test and also encourages the use of mocks in unit tests.
  • Favor composition over inheritance: Rather than building complex class hierarchies, compose objects using injected dependencies to keep components small and focused.
  • Centralized dependency configuration: Utilize a single container or module to define and manage dependencies, making overriding and testing easier.
  • Avoid circular dependencies: Carefully design your dependency graph to prevent circular references by using factory functions or providers to delay instantiation.

Common pitfalls

Here are some common pitfalls you should avoid when using dependency injection.

  • Service locator anti-pattern: Avoid using a global container to dynamically fetch dependencies, as this can conceal dependencies and make code complicated to understand and test.
  • Over-injection: Avoid injecting too many services into a single class or function to prevent your code from leading to bloated constructors and hard-to-test logic.
  • Scope mismanagement and state leakage: Ensure you always use the right scope to avoid sharing state across users or tests.

Dependency Injection Testing in Python

Dependency injection makes it easy to inject mock objects into your components, thereby simplifying unit tests. This allows you to separate fundamental components from test-specific ones.

With dependency injection, tests are deterministic, have no side effects, and result in faster test execution. 

Here is an example showing how you can use dependency Injection to inject mock, stub, or fake versions of those dependencies in your tests. 

class EmailService:
    def send_email(self, to: str, message: str): ...

class Notifier:
    def __init__(self, email_service: EmailService):
        self.email_service = email_service

    def alert(self, user_email):
        self.email_service.send_email(user_email, "Alert!")

In tests, replace EmailService with a mock:

class TestEmailService:
    def send_email(self, to, message):
        self.sent_to = to

def test_notifier_sends_email():
    test_email = TestEmailService()
    notifier = Notifier(test_email)

    notifier.alert("test@example.com")
    assert test_email.sent_to == "test@example.com"

Ensure to use dependency injection often and avoid monkey patching for maintainability and  avoid breaking changes when internal name changes.

Conclusion

Although you might not need dependency injection initially for small projects, as the project grows and scales, you can benefit from the structure and modularity offered by dependency injection. 

Looking ahead, the future of dependency injection in Python is promising, and we can expect it to continue with an improved type system and enhanced async lifecycle management.

Mastering dependency injection is essential for becoming a fluent Python developer. But it doesn’t stop there; many other advanced concepts can elevate your Python skills. 

To help you accelerate your learning journey, here are some of our resources you should look at.

Courses:

Blog Posts:


Adejumo Ridwan Suleiman's photo
Author
Adejumo Ridwan Suleiman
LinkedIn

Experienced data science instructor and Biostatistician with expertise in Python, R, and machine learning.

FAQs

What is dependency Injection?

Dependency injection is a design pattern that enables you to write cleaner code by creating dependencies within a class, which can be passed from outside, making your code easier to manage and modify.

What are the main benefits of dependency Injection?

Dependency injection makes your code modular, testable, and maintainable.

How can I implement dependency injection in Python?

You can implement dependency injection in Python either manually or by using libraries such as dependency_injector.

What is asynchronous Injection?

Asynchronous injection refers to injecting dependencies that are async def functions or managing async resources like async database connections, external APIs, or background tasks.

What are the security risks of dependency injection, and how can I handle them?

One significant risk is dependency confusion, where malicious packages mimic legitimate ones. To prevent this, you can use tools like Snyk, Xygeni, Safety, or Bandit to scan your code and packages for vulnerabilities.

Topics

Learn Python with DataCamp

Course

Introduction to Python

4 hr
6.6M
Master the basics of data analysis with Python in just four hours. This online course will introduce the Python interface and explore popular packages.
See DetailsRight Arrow
Start Course
See MoreRight Arrow
Related
Data Skills

blog

6 Python Best Practices for Better Code

Discover the Python coding best practices for writing best-in-class Python scripts.
Javier Canales Luna's photo

Javier Canales Luna

13 min

Tutorial

Python Poetry: Modern And Efficient Python Environment And Dependency Management

Learn how to manage dependencies and virtual environments for complex projects using Python Poetry.
Bex Tuychiev's photo

Bex Tuychiev

Tutorial

Python Modules Tutorial: Importing, Writing, and Using Them

Learn how to create and import Python modules. Discover best practices, examples, and tips for writing reusable, organized, and efficient Python code!

Nishant Kumar

Tutorial

Pip Python Tutorial for Package Management

Learn about Pip, a powerful tool that helps you properly manage distribution packages in Python.
Javier Canales Luna's photo

Javier Canales Luna

Tutorial

How to Document Python Code

Learn why there is a need for documenting code and best practices to do it. Further, learn to leverage the potential of the Pydoc module for documenting purposes.
Aditya Sharma's photo

Aditya Sharma

Tutorial

Unit Testing in Python Tutorial

Learn what unit testing is, why its important, and how you can implement it with the help of Python.
Abid Ali Awan's photo

Abid Ali Awan

See MoreSee More