Python Dependency Injection — Deep Dive
The Composition Root Pattern
The most important concept in DI architecture is the composition root — the single place where you wire all dependencies together. In Python web apps, this is typically your application factory:
# app/factory.py
from app.services import UserService, OrderService, NotificationService
from app.repositories import PostgresUserRepo, PostgresOrderRepo
from app.clients import SmtpEmailClient, StripePaymentClient
def create_app(config):
# Infrastructure
db = Database(config.DATABASE_URL)
redis = Redis(config.REDIS_URL)
# Repositories
user_repo = PostgresUserRepo(db)
order_repo = PostgresOrderRepo(db)
# External clients
email_client = SmtpEmailClient(config.SMTP_HOST, config.SMTP_PORT)
payment_client = StripePaymentClient(config.STRIPE_KEY)
# Services (depend on repos and clients)
notification_svc = NotificationService(email_client)
user_svc = UserService(user_repo, notification_svc)
order_svc = OrderService(order_repo, payment_client, notification_svc)
# App
app = App()
app.user_service = user_svc
app.order_service = order_svc
return app
Everything is wired in one place. No service knows how another is created. No global state, no hidden imports.
Protocol-Based DI with Type Safety
Python’s Protocol class (from typing) lets you define interfaces without inheritance, making DI type-safe without coupling:
from typing import Protocol
class EmailSender(Protocol):
def send(self, to: str, subject: str, body: str) -> None: ...
class UserRepository(Protocol):
def get_by_id(self, user_id: int) -> User | None: ...
def save(self, user: User) -> None: ...
class UserService:
def __init__(self, repo: UserRepository, email: EmailSender):
self.repo = repo
self.email = email
Any class that implements send(to, subject, body) satisfies EmailSender — no base class needed. This is structural subtyping (duck typing with type checker support), and it’s ideal for DI because it keeps components decoupled while maintaining type safety.
FastAPI’s Depends System
FastAPI has the most elegant DI system in the Python web ecosystem. Dependencies are functions that get called automatically:
from fastapi import Depends, FastAPI
app = FastAPI()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
def get_user_service(db=Depends(get_db)):
return UserService(db)
@app.get("/users/{user_id}")
async def get_user(user_id: int, service=Depends(get_user_service)):
return service.get_by_id(user_id)
Key features of FastAPI’s DI:
- Dependency chains —
get_user_servicedepends onget_db, which is auto-resolved - Lifecycle management —
yielddependencies get cleanup (thefinallyblock runs after the response) - Caching per request — If multiple endpoints depend on
get_db, it’s called once per request - Override for testing —
app.dependency_overrides[get_db] = lambda: fake_db
The dependency-injector Library
For larger applications that need a full container, dependency-injector provides explicit, declarative wiring:
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
db = providers.Singleton(Database, url=config.db_url)
user_repo = providers.Factory(UserRepository, db=db)
user_service = providers.Factory(
UserService,
repo=user_repo,
email=providers.Factory(SmtpEmailClient, host=config.smtp_host),
)
The container is a blueprint. Nothing is created until you access a provider. Singleton creates once and reuses; Factory creates fresh each time.
Override for testing:
container = Container()
container.user_repo.override(providers.Factory(FakeUserRepo))
Scoped Dependencies
Web applications need per-request scoping: each request gets its own database session, but the connection pool is shared. DI containers handle this with scope providers:
# Per-request scope in dependency-injector
db_session = providers.Resource(
get_session,
pool=db_pool,
)
In FastAPI, yield dependencies naturally provide per-request scope. In Django, middleware creates and cleans up per-request resources.
Manual implementation without a framework:
import contextvars
_current_session = contextvars.ContextVar("db_session")
def get_current_session():
return _current_session.get()
# In middleware
async def db_middleware(request, call_next):
session = SessionLocal()
token = _current_session.set(session)
try:
response = await call_next(request)
session.commit()
return response
except Exception:
session.rollback()
raise
finally:
session.close()
_current_session.reset(token)
Testing Strategies
Fakes Over Mocks
DI makes fakes trivial. Fakes are real implementations that work in memory:
class InMemoryUserRepo:
def __init__(self):
self.users = {}
self._next_id = 1
def save(self, user):
if not user.id:
user.id = self._next_id
self._next_id += 1
self.users[user.id] = user
def get_by_id(self, user_id):
return self.users.get(user_id)
Fakes are better than mocks because they have real behavior. A mock returns whatever you tell it; a fake catches bugs in how your code interacts with the dependency.
Test Configuration
Create a test composition root that wires fakes:
def create_test_app():
user_repo = InMemoryUserRepo()
email = FakeEmailSender()
user_svc = UserService(repo=user_repo, email=email)
return App(user_service=user_svc), user_repo, email
Tests get direct access to fakes for assertions:
def test_registration_sends_welcome_email():
app, user_repo, email = create_test_app()
app.user_service.register("alice@example.com", "Alice")
assert len(email.sent) == 1
assert "welcome" in email.sent[0].subject.lower()
Anti-Patterns
Service locator — A global registry that services pull dependencies from. It hides what depends on what and makes testing harder. Prefer explicit constructor injection.
God container — A single container that wires hundreds of services. Split into sub-containers by domain (user_container, order_container).
Injecting the container — Passing the entire DI container into a service defeats the purpose. Services should receive only what they need, not a menu of everything available.
Over-abstracting — Creating interfaces for services that only have one implementation adds indirection without benefit. Use protocols when you actually have multiple implementations or need testability.
When to Avoid DI
Small scripts and CLI tools rarely benefit from DI. If a module has 2-3 dependencies and no tests, constructor injection adds ceremony without payoff. DI shines in applications with complex dependency graphs, extensive test suites, and multiple deployment environments.
The one thing to remember: Effective DI in Python combines the composition root pattern with protocols for type safety — wire everything at startup, keep services unaware of each other’s creation, and use fakes instead of mocks for testing.
See Also
- Python Aggregate Pattern Why grouping related objects under a single gatekeeper prevents data chaos in your Python application.
- Python Bounded Contexts Why the same word means different things in different parts of your code — and why that is perfectly fine.
- Python Bulkhead Pattern Why smart Python apps put walls between their parts — like a ship that stays afloat even with a hole in the hull.
- Python Circuit Breaker Pattern How a circuit breaker saves your app from crashing — explained with a home electrical fuse analogy.
- Python Clean Architecture Why your Python app should look like an onion — and how that saves you from painful rewrites.