DEV Community

Cover image for Dependency Injection: a Python Way
Rost
Rost

Posted on • Originally published at glukhov.org

Dependency Injection: a Python Way

Dependency injection (DI) is a fundamental design pattern that promotes clean, testable, and maintainable code in Python applications.

Whether you're building REST APIs with FastAPI, implementing unit tests, or working with AWS Lambda functions, understanding dependency injection will significantly improve your code quality.

What is Dependency Injection?

Dependency injection is a design pattern where components receive their dependencies from external sources rather than creating them internally. This approach decouples components, making your code more modular, testable, and maintainable.

In Python, dependency injection is particularly powerful because of the language's dynamic nature and support for protocols, abstract base classes, and duck typing. Python's flexibility means you can implement DI patterns without heavy frameworks, though frameworks are available when needed.

Why Use Dependency Injection in Python?

Improved Testability: By injecting dependencies, you can easily replace real implementations with mocks or test doubles. This allows you to write unit tests that are fast, isolated, and don't require external services like databases or APIs. When writing comprehensive unit tests, dependency injection makes it trivial to swap real dependencies with test doubles.

Better Maintainability: Dependencies become explicit in your code. When you look at a constructor, you immediately see what a component requires. This makes the codebase easier to understand and modify.

Loose Coupling: Components depend on abstractions (protocols or ABCs) rather than concrete implementations. This means you can change implementations without affecting dependent code.

Flexibility: You can configure different implementations for different environments (development, testing, production) without changing your business logic. This is especially useful when deploying Python applications to different platforms, whether it's AWS Lambda or traditional servers.

Constructor Injection: The Python Way

The most common and idiomatic way to implement dependency injection in Python is through constructor injection—accepting dependencies as parameters in the __init__ method.

Basic Example

Here's a simple example demonstrating constructor injection:

from typing import Protocol
from abc import ABC, abstractmethod

# Define a protocol for the repository
class UserRepository(Protocol):
    def find_by_id(self, user_id: int) -> 'User | None':
        ...

    def save(self, user: 'User') -> 'User':
        ...

# Service depends on the repository protocol
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo

    def get_user(self, user_id: int) -> 'User | None':
        return self.repo.find_by_id(user_id)
Enter fullscreen mode Exit fullscreen mode

This pattern makes it clear that UserService requires a UserRepository. You cannot create a UserService without providing a repository, which prevents runtime errors from missing dependencies.

Multiple Dependencies

When a component has multiple dependencies, simply add them as constructor parameters:

class EmailService(Protocol):
    def send(self, to: str, subject: str, body: str) -> None:
        ...

class Logger(Protocol):
    def info(self, msg: str) -> None:
        ...

    def error(self, msg: str, err: Exception) -> None:
        ...

class OrderService:
    def __init__(
        self,
        repo: OrderRepository,
        email_svc: EmailService,
        logger: Logger,
        payment_svc: PaymentService,
    ):
        self.repo = repo
        self.email_svc = email_svc
        self.logger = logger
        self.payment_svc = payment_svc
Enter fullscreen mode Exit fullscreen mode

Using Protocols and Abstract Base Classes

One of the key principles when implementing dependency injection is the Dependency Inversion Principle (DIP): high-level modules should not depend on low-level modules; both should depend on abstractions.

In Python, you can define abstractions using either Protocols (structural typing) or Abstract Base Classes (ABCs) (nominal typing).

Protocols (Python 3.8+)

Protocols use structural typing—if an object has the required methods, it satisfies the protocol:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

# Any class with process_payment method satisfies this protocol
class CreditCardProcessor:
    def process_payment(self, amount: float) -> bool:
        # Credit card logic
        return True

class PayPalProcessor:
    def process_payment(self, amount: float) -> bool:
        # PayPal logic
        return True

# Service accepts any PaymentProcessor
class OrderService:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor
Enter fullscreen mode Exit fullscreen mode

Abstract Base Classes

ABCs use nominal typing—classes must explicitly inherit from the ABC:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

class CreditCardProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        return True
Enter fullscreen mode Exit fullscreen mode

When to use Protocols vs ABCs: Use Protocols when you want structural typing and flexibility. Use ABCs when you need to enforce inheritance hierarchies or provide default implementations.

Real-World Example: Database Abstraction

When working with databases in Python applications, you'll often need to abstract database operations. Here's how dependency injection helps:

from typing import Protocol, Optional
from contextlib import contextmanager

class Database(Protocol):
    @contextmanager
    def transaction(self):
        ...

    def execute(self, query: str, params: dict) -> None:
        ...

    def fetch_one(self, query: str, params: dict) -> Optional[dict]:
        ...

# Repository depends on the abstraction
class UserRepository:
    def __init__(self, db: Database):
        self.db = db

    def find_by_id(self, user_id: int) -> Optional['User']:
        result = self.db.fetch_one(
            "SELECT * FROM users WHERE id = :id",
            {"id": user_id}
        )
        if result:
            return User(**result)
        return None
Enter fullscreen mode Exit fullscreen mode

This pattern allows you to swap database implementations (PostgreSQL, SQLite, MongoDB) without changing your repository code.

The Composition Root Pattern

The Composition Root is where you assemble all your dependencies at the application's entry point (typically main.py or your application factory). This centralizes dependency configuration and makes the dependency graph explicit.

def create_app() -> FastAPI:
    app = FastAPI()

    # Initialize infrastructure dependencies
    db = init_database()
    logger = init_logger()

    # Initialize repositories
    user_repo = UserRepository(db)
    order_repo = OrderRepository(db)

    # Initialize services with dependencies
    email_svc = EmailService(logger)
    payment_svc = PaymentService(logger)
    user_svc = UserService(user_repo, logger)
    order_svc = OrderService(order_repo, email_svc, logger, payment_svc)

    # Initialize HTTP handlers
    user_handler = UserHandler(user_svc)
    order_handler = OrderHandler(order_svc)

    # Wire up routes
    app.include_router(user_handler.router)
    app.include_router(order_handler.router)

    return app
Enter fullscreen mode Exit fullscreen mode

This approach makes it clear how your application is structured and where dependencies come from. It's particularly valuable when building applications following clean architecture principles, where you need to coordinate multiple layers of dependencies.

Dependency Injection Frameworks

For larger applications with complex dependency graphs, managing dependencies manually can become cumbersome. Python has several DI frameworks that can help:

Dependency Injector

Dependency Injector is a popular framework that provides a container-based approach to dependency injection.

Installation:

pip install dependency-injector
Enter fullscreen mode Exit fullscreen mode

Example:

from dependency_injector import containers, providers
from dependency_injector.wiring import inject, Provide

class Container(containers.DeclarativeContainer):
    # Configuration
    config = providers.Configuration()

    # Database
    db = providers.Singleton(
        Database,
        connection_string=config.database.url
    )

    # Repositories
    user_repository = providers.Factory(
        UserRepository,
        db=db
    )

    # Services
    user_service = providers.Factory(
        UserService,
        repo=user_repository
    )

# Usage
container = Container()
container.config.database.url.from_env("DATABASE_URL")
user_service = container.user_service()
Enter fullscreen mode Exit fullscreen mode

Injector

Injector is a lightweight library inspired by Google's Guice, focusing on simplicity.

Installation:

pip install injector
Enter fullscreen mode Exit fullscreen mode

Example:

from injector import Injector, inject, Module, provider

class DatabaseModule(Module):
    @provider
    def provide_db(self) -> Database:
        return Database(connection_string="...")

class UserModule(Module):
    @inject
    def __init__(self, repo: UserRepository):
        self.repo = repo

injector = Injector([DatabaseModule()])
user_service = injector.get(UserService)
Enter fullscreen mode Exit fullscreen mode

When to Use Frameworks

Use a framework when:

  • Your dependency graph is complex with many interdependent components
  • You have multiple implementations of the same interface that need to be selected based on configuration
  • You want automatic dependency resolution
  • You're building a large application where manual wiring becomes error-prone

Stick with manual DI when:

  • Your application is small to medium-sized
  • The dependency graph is simple and easy to follow
  • You want to keep dependencies minimal and explicit
  • You prefer explicit code over framework magic

Testing with Dependency Injection

One of the primary benefits of dependency injection is improved testability. Here's how DI makes testing easier:

Unit Testing Example

from unittest.mock import Mock
import pytest

# Mock implementation for testing
class MockUserRepository:
    def __init__(self):
        self.users = {}
        self.error = None

    def find_by_id(self, user_id: int) -> Optional['User']:
        if self.error:
            raise self.error
        return self.users.get(user_id)

    def save(self, user: 'User') -> 'User':
        if self.error:
            raise self.error
        self.users[user.id] = user
        return user

# Test using the mock
def test_user_service_get_user():
    mock_repo = MockUserRepository()
    mock_repo.users[1] = User(id=1, name="John", email="john@example.com")

    service = UserService(mock_repo)

    user = service.get_user(1)
    assert user is not None
    assert user.name == "John"
Enter fullscreen mode Exit fullscreen mode

This test runs quickly, doesn't require a database, and tests your business logic in isolation. When working with unit testing in Python, dependency injection makes it easy to create test doubles and verify interactions.

Using pytest Fixtures

pytest fixtures work excellently with dependency injection:

@pytest.fixture
def mock_user_repository():
    return MockUserRepository()

@pytest.fixture
def user_service(mock_user_repository):
    return UserService(mock_user_repository)

def test_user_service_get_user(user_service, mock_user_repository):
    user = User(id=1, name="John", email="john@example.com")
    mock_user_repository.users[1] = user

    result = user_service.get_user(1)
    assert result.name == "John"
Enter fullscreen mode Exit fullscreen mode

Common Patterns and Best Practices

1. Use Interface Segregation

Keep protocols and interfaces small and focused on what the client actually needs:

# Good: Client only needs to read users
class UserReader(Protocol):
    def find_by_id(self, user_id: int) -> Optional['User']:
        ...

    def find_by_email(self, email: str) -> Optional['User']:
        ...

# Separate interface for writing
class UserWriter(Protocol):
    def save(self, user: 'User') -> 'User':
        ...

    def delete(self, user_id: int) -> bool:
        ...
Enter fullscreen mode Exit fullscreen mode

2. Validate Dependencies in Constructors

Constructors should validate dependencies and raise clear errors if initialization fails:

class UserService:
    def __init__(self, repo: UserRepository):
        if repo is None:
            raise ValueError("user repository cannot be None")
        self.repo = repo
Enter fullscreen mode Exit fullscreen mode

3. Use Type Hints

Type hints make dependencies explicit and help with IDE support and static type checking:

from typing import Protocol, Optional

class UserService:
    def __init__(
        self,
        repo: UserRepository,
        logger: Logger,
        email_service: EmailService,
    ) -> None:
        self.repo = repo
        self.logger = logger
        self.email_service = email_service
Enter fullscreen mode Exit fullscreen mode

4. Avoid Over-Injection

Don't inject dependencies that are truly internal implementation details. If a component creates and manages its own helper objects, that's fine:

# Good: Internal helper doesn't need injection
class UserService:
    def __init__(self, repo: UserRepository):
        self.repo = repo
        # Internal cache - doesn't need injection
        self._cache: dict[int, User] = {}
Enter fullscreen mode Exit fullscreen mode

5. Document Dependencies

Use docstrings to document why dependencies are needed and any constraints:

class UserService:
    """UserService handles user-related business logic.

    Args:
        repo: UserRepository for data access. Must be thread-safe
            if used in concurrent contexts.
        logger: Logger for error tracking and debugging.
    """
    def __init__(self, repo: UserRepository, logger: Logger):
        self.repo = repo
        self.logger = logger
Enter fullscreen mode Exit fullscreen mode

Dependency Injection with FastAPI

FastAPI has built-in support for dependency injection through its Depends mechanism:

from fastapi import FastAPI, Depends
from typing import Annotated

app = FastAPI()

def get_user_repository() -> UserRepository:
    db = get_database()
    return UserRepository(db)

def get_user_service(
    repo: Annotated[UserRepository, Depends(get_user_repository)]
) -> UserService:
    return UserService(repo)

@app.get("/users/{user_id}")
def get_user(
    user_id: int,
    service: Annotated[UserService, Depends(get_user_service)]
):
    user = service.get_user(user_id)
    if not user:
        raise HTTPException(status_code=404)
    return user
Enter fullscreen mode Exit fullscreen mode

FastAPI's dependency injection system handles the dependency graph automatically, making it easy to build clean, maintainable APIs.

When NOT to Use Dependency Injection

Dependency injection is a powerful tool, but it's not always necessary:

Skip DI for:

  • Simple value objects or data classes
  • Internal helper functions or utilities
  • One-off scripts or small utilities
  • When direct instantiation is clearer and simpler

Example of when NOT to use DI:

# Simple dataclass - no need for DI
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

# Simple utility - no need for DI
def format_currency(amount: float) -> str:
    return f"${amount:.2f}"
Enter fullscreen mode Exit fullscreen mode

Integration with Python Ecosystem

Dependency injection works seamlessly with other Python patterns and tools. When building applications that use Python packages or unit testing frameworks, you can inject these services into your business logic:

class ReportService:
    def __init__(
        self,
        pdf_generator: PDFGenerator,
        repo: ReportRepository,
        logger: Logger,
    ):
        self.pdf_generator = pdf_generator
        self.repo = repo
        self.logger = logger

    def generate_report(self, report_id: int) -> bytes:
        report_data = self.repo.get_by_id(report_id)
        pdf = self.pdf_generator.generate(report_data)
        self.logger.info(f"Generated report {report_id}")
        return pdf
Enter fullscreen mode Exit fullscreen mode

This allows you to swap implementations or use mocks during testing.

Async Dependency Injection

Python's async/await syntax works well with dependency injection:

from typing import Protocol
import asyncio

class AsyncUserRepository(Protocol):
    async def find_by_id(self, user_id: int) -> Optional['User']:
        ...

    async def save(self, user: 'User') -> 'User':
        ...

class AsyncUserService:
    def __init__(self, repo: AsyncUserRepository):
        self.repo = repo

    async def get_user(self, user_id: int) -> Optional['User']:
        return await self.repo.find_by_id(user_id)

    async def get_users_batch(self, user_ids: list[int]) -> list['User']:
        tasks = [self.repo.find_by_id(uid) for uid in user_ids]
        results = await asyncio.gather(*tasks)
        return [u for u in results if u is not None]
Enter fullscreen mode Exit fullscreen mode

Conclusion

Dependency injection is a cornerstone of writing maintainable, testable Python code. By following the patterns outlined in this article—constructor injection, protocol-based design, and the composition root pattern—you'll create applications that are easier to understand, test, and modify.

Start with manual constructor injection for small to medium applications, and consider frameworks like dependency-injector or injector as your dependency graph grows. Remember that the goal is clarity and testability, not complexity for its own sake.

For more Python development resources, check out our Python Cheatsheet for quick reference on Python syntax and common patterns.

Useful links

External Resources

Top comments (0)