
Implementing Dependency Injection in Python for Cleaner, Testable Code
Learn how to use **dependency injection (DI)** in Python to write cleaner, more maintainable, and highly testable code. This practical guide breaks DI down into core concepts, concrete patterns, and complete code examples — including how DI improves unit testing with **Pytest**, leverages **dataclasses**, and fits into a packaged Python project ready for distribution.
Introduction
Why should you care about Dependency Injection (DI)? Have you ever tried to unit-test a class that reached directly for the database, filesystem, or a global client? DI helps you decouple components so they are easier to reason about, substitute with fakes or mocks, and test in isolation.
In this post you'll learn:
- Core DI concepts and when to use them.
- Multiple injection strategies with pythonic examples.
- How DI interacts with testing (Pytest), data modeling (dataclasses), and packaging (creating a distributable package).
- Best practices, edge cases, and a minimal IoC container you can extend.
Prerequisites and Setup
Before we dive into code, ensure you have:
- Python 3.8+ (for typing.Protocol; 3.10+ recommended for nicer syntax).
- pytest installed for the testing examples: pip install pytest
- Optionally: an editor that supports syntax highlighting.
- myapp/
Core Concepts (Broken Down)
- Dependency: a service or object your class uses to do work (e.g., a repository).
- Injection: passing the dependency into the consumer instead of the consumer creating it.
- Inversion of Control (IoC): control over creating dependencies is inverted and often centralized.
- Interface / Protocol: an abstract contract clients rely on rather than concrete implementations.
- Container / Provider: optional object that resolves bindings at runtime.
- Easier unit testing: you inject a fake implementation or mock.
- Better separation of concerns: classes focus on behavior, not wiring.
- Easier to swap implementations (e.g., from in-memory to SQL).
- Constructor injection (preferred).
- Setter/property injection.
- Method injection (pass a dependency to the method).
- Service Locator (less favored because of hidden dependencies).
Step-by-Step Example: A Small App with DI
Scenario: A service fetches and processes user data from a repository. We'll demonstrate:
- Define an interface (Protocol).
- Provide concrete implementations.
- Inject via constructor.
- Show tests with pytest mocking the repository.
- Show a tiny container for wiring.
1) Define an interface using Protocol
# models.py
from dataclasses import dataclass
@dataclass
class User:
id: int
name: str
email: str
Explanation, line-by-line:
- We use a dataclass to define a simple DTO. Dataclasses reduce boilerplate (auto __init__, __repr__, equality).
- Fields: id, name, email. This is ideal for passing structured data between components.
# repositories.py
from typing import Protocol, List
from models import User
class UserRepository(Protocol):
def get_user(self, user_id: int) -> User:
...
def list_users(self) -> List[User]:
...
Explanation:
Protocol
(PEP 544) provides a structural typing interface. Any object implementing the required methods matches the protocol.- This decouples the consumer from concrete classes.
2) Concrete implementations
# repositories.py (continued)
from dataclasses import dataclass
@dataclass
class InMemoryUserRepository:
users: dict
def get_user(self, user_id: int) -> User:
user = self.users.get(user_id)
if not user:
raise KeyError(f"User {user_id} not found")
return user
def list_users(self):
return list(self.users.values())
Explanation:
- InMemoryUserRepository stores users in a dict. Good for tests and local dev.
- Error handling: raises KeyError when user missing (caller should handle).
- You can add a SQL-backed implementation that follows the same Protocol.
3) The service that depends on the repository (Constructor Injection)
# services.py
from repositories import UserRepository
from models import User
from typing import List
class UserService:
def __init__(self, repo: UserRepository):
# Constructor Injection: we receive the dependency
self._repo = repo
def get_display_name(self, user_id: int) -> str:
user: User = self._repo.get_user(user_id)
return f"{user.name} <{user.email}>"
def list_names(self) -> List[str]:
return [u.name for u in self._repo.list_users()]
Explanation:
- The service depends only on
UserRepository
Protocol, not on concrete classes. - Constructor injection makes dependency explicit and test-friendly.
4) Using the service in application code
# main.py (example usage)
from repositories import InMemoryUserRepository
from services import UserService
from models import User
users = {
1: User(1, "Alice", "alice@example.com"),
2: User(2, "Bob", "bob@example.com"),
}
repo = InMemoryUserRepository(users)
service = UserService(repo)
print(service.get_display_name(1)) # Output: Alice
Explanation:
- We wire implementations at application bootstrap, not inside service classes.
Testing with Pytest — Automated Testing Suite
DI shines for tests. You can pass deterministic or mocked dependencies.
# tests/test_services.py
import pytest
from services import UserService
from models import User
class FakeRepo:
def __init__(self):
self._users = {1: User(1, "Fake", "fake@example.com")}
def get_user(self, user_id: int):
return self._users[user_id]
def list_users(self):
return list(self._users.values())
@pytest.fixture
def fake_repo():
return FakeRepo()
def test_get_display_name(fake_repo):
svc = UserService(fake_repo)
assert svc.get_display_name(1) == "Fake "
def test_list_names_empty():
class EmptyRepo:
def list_users(self): return []
svc = UserService(EmptyRepo())
assert svc.list_names() == []
Explanation:
- Use pytest fixtures to provide reusable fakes.
- Tests remain fast without any I/O.
- Edge cases: verify behavior with empty repositories and missing user handling.
- Group tests by feature and mirror package structure.
- Use fixtures for common objects and parametrization for multiple cases.
- Keep tests deterministic and fast; avoid network/db in unit tests — use DI to inject fakes.
- Use pytest.ini to set markers and configure test paths.
Example: Using unittest.mock for more control
# tests/test_services_mock.py
from unittest.mock import Mock
from services import UserService
from models import User
def test_get_display_name_with_mock():
repo_mock = Mock()
repo_mock.get_user.return_value = User(42, "Mocky", "m@example.com")
svc = UserService(repo_mock)
assert svc.get_display_name(42) == "Mocky "
repo_mock.get_user.assert_called_once_with(42)
Explanation:
unittest.mock.Mock
provides assertions on how dependencies were used.- Great for behavioral verification.
Lightweight IoC Container (Optional)
For larger apps, you might centralize wiring:
# container.py
from repositories import InMemoryUserRepository
from services import UserService
from models import User
class Container:
def __init__(self):
self._singletons = {}
def register_singleton(self, name, instance):
self._singletons[name] = instance
def resolve(self, name):
return self._singletons[name]
bootstrap
def create_container():
c = Container()
users = {1: User(1, "Alice", "alice@example.com")}
c.register_singleton("user_repo", InMemoryUserRepository(users))
c.register_singleton("user_service", UserService(c.resolve("user_repo")))
return c
Explanation:
- Simple registry mapping names to instances.
- Avoids hidden dependencies if you explicitly resolve in bootstrap code; do not use global service locators inside business logic.
Exploring Python's Data Classes: How They Fit DI
We used dataclasses for the User
model. Benefits:
- Less boilerplate for data containers.
- Built-in immutability options: use
@dataclass(frozen=True)
to prevent accidental mutation. - Clear, typed fields aid readability and static analysis.
from dataclasses import dataclass
@dataclass(frozen=True)
class Config:
db_url: str
max_connections: int = 5
Use Config
as a dependency to services, injected at startup.
Creating a Custom Python Package: Packaging & Versioning
When your DI-based project grows, package it:
Minimal steps:
- Create package structure:
- Add pyproject.toml (setuptools or Poetry). Minimal example for setuptools:
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "myapp"
version = "0.1.0"
description = "Example app demonstrating DI"
authors = [{name = "You"}]
dependencies = []
- Versioning:
- Use semantic versioning (MAJOR.MINOR.PATCH).
- Use tools like
bump2version
or Git tags.
- Testing and CI:
- Add a CI pipeline (GitHub Actions) to run
pytest
. - Ensure tests use DI to avoid external dependencies in unit tests.
Best Practices
- Prefer constructor injection for mandatory dependencies.
- Use Protocols / Abstract Base Classes to define contracts.
- Keep object creation in bootstrap code — avoid factories inside business methods.
- Use
dataclasses
for payloads/config which makes your code clearer and helps testing. - Make DI explicit. Hidden dependencies lead to brittle code.
- Write fast unit tests with pytest by injecting fakes or mocks.
- For large systems, use established DI libraries sparingly (e.g., injector) or a simple container to avoid overengineering.
Common Pitfalls and How to Avoid Them
- Hidden dependencies: Avoid reading global singletons inside classes; inject instead.
- Circular dependencies: Can occur when A depends on B and B depends on A. Resolve by refactoring responsibilities or using late binding (providers).
- Overuse of containers: A container that resolves everything by string keys can hide the real structure of your app; prefer explicit wiring.
- Performance: Reflection-heavy DI frameworks can add overhead. Pythonic DI is usually lightweight (constructor injection + simple factories).
- Error handling when dependency missing: Provide clear error messages at bootstrap time, not deep inside methods.
Advanced Tips
- Use
typing.Protocol
and static type checkers (mypy) to ensure implementations match interface expectations. - Use
@dataclass
combined withpydantic
if you need validation for injected configurations. - Consider factories or providers for expensive dependencies (DB connections); create on demand with caching.
- For integration tests, use configuration to swap real implementations for test doubles (e.g., using test container in CI).
Performance Considerations
- DI itself is mostly about architecture; performance impacts are negligible with constructor injection.
- Avoid building massive containers at runtime repeatedly; create singletons during bootstrap.
- For high-throughput systems, ensure heavy objects like DB pools are reused.
Conclusion
Dependency Injection in Python helps you write cleaner, decoupled, and testable code. With simple strategies — constructor injection, Protocols, dataclasses for models, and pytest for tests — you can build solid, maintainable systems without heavy frameworks.
Call to action: Try refactoring a small module in your codebase to use DI. Create a simple fake repo and write pytest tests for your services. If you liked this walkthrough, consider packaging your module and adding automated tests in CI — it's a satisfying progression from code hygiene to production readiness.
Further Reading and References
- Python typing.Protocol: https://docs.python.org/3/library/typing.html#typing.Protocol
- Dataclasses documentation: https://docs.python.org/3/library/dataclasses.html
- pytest official docs: https://docs.pytest.org/
- Packaging (PEP 517/518): https://packaging.python.org/
- For DI libraries (optional): https://injector.readthedocs.io/
- Building an Automated Testing Suite with Pytest: Best Practices and Examples
- Exploring Python's Data Classes: A Comprehensive Guide to Simplified Data Handling
- Creating a Custom Python Package: Step-by-Step Guide to Distribution and Versioning
Was this article helpful?
Your feedback helps us improve our content. Thank you!