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)
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
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
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
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
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
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
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()
Injector
Injector is a lightweight library inspired by Google's Guice, focusing on simplicity.
Installation:
pip install injector
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)
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"
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"
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:
...
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
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
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] = {}
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
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
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}"
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
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]
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
- Python Cheatsheet
- Unit Testing in Python
- Python Design Patterns for Clean Architecture
- Structured Output - LLMs on Ollama with Qwen3 - Python and Go
- Structured output comparison across popular LLM providers - OpenAI, Gemini, Anthropic, Mistral and AWS Bedrock
- Building a Dual-Mode AWS Lambda with Python and Terraform
External Resources
- Dependency Injection in Python - Real Python
- How Dependency Injection in Python Improves Code Structure - Volito Digital
- Python Dependency Injection Tutorial - DataCamp
- Dependency Injector - Official Documentation
- Injector - Lightweight DI Framework
- FastAPI Dependencies - Official Docs
- SOLID Principles in Python - Software Patterns Lexicon
- Python Protocols and Structural Subtyping - PEP 544
Top comments (0)