Python Dependency Injection: Decoupled Architecture
Dependency injection (DI) is the practice of passing dependencies into a class or function rather than creating them internally. It makes code testable (swap real dependencies for mocks), configurable (swap implementations without changing callers), and maintainable (dependencies are explicit). Python doesn't require a heavy DI framework — constructor injection and FastAPI's Depends system cover most production needs elegantly.
Table of Contents
Why Dependency Injection?
# WITHOUT DI — hard to test, hard to swap implementations
class OrderService:
def __init__(self):
self.db = PostgresDatabase("postgresql://localhost/orders") # hardcoded!
self.email = SmtpEmailSender("smtp.gmail.com", 587) # hardcoded!
self.payment = StripePaymentGateway(api_key="sk_live_...") # hardcoded!
def place_order(self, order):
# Impossible to test without real DB, SMTP server, and Stripe
pass
# WITH DI — testable, configurable, flexible
class OrderService:
def __init__(self, db: Database, email: EmailSender, payment: PaymentGateway):
self.db = db
self.email = email
self.payment = payment
def place_order(self, order):
# Works with any implementation of Database, EmailSender, PaymentGateway
pass
# Production: inject real dependencies
service = OrderService(
db=PostgresDatabase(os.environ["DATABASE_URL"]),
email=SmtpEmailSender(os.environ["SMTP_HOST"]),
payment=StripePaymentGateway(os.environ["STRIPE_KEY"]),
)
# Tests: inject fakes
service = OrderService(
db=InMemoryDatabase(),
email=FakeEmailSender(),
payment=FakePaymentGateway(),
)
Constructor Injection
Constructor injection is the cleanest DI approach — all dependencies are explicit in the __init__ signature. Pair it with Python's Protocol (structural subtyping) instead of ABC inheritance for flexible interfaces.
from typing import Protocol, runtime_checkable
from abc import abstractmethod
# Define interfaces as Protocols — no inheritance required
@runtime_checkable
class UserRepository(Protocol):
async def find_by_id(self, user_id: int) -> dict | None: ...
async def save(self, user: dict) -> dict: ...
async def delete(self, user_id: int) -> bool: ...
@runtime_checkable
class EmailService(Protocol):
async def send(self, to: str, subject: str, body: str) -> None: ...
# Service depends on protocols (interfaces), not concrete classes
class UserService:
def __init__(self, users: UserRepository, email: EmailService):
self._users = users
self._email = email
async def register(self, username: str, email_addr: str) -> dict:
user = await self._users.save({"username": username, "email": email_addr})
await self._email.send(email_addr, "Welcome!", f"Hi {username}, welcome!")
return user
async def deactivate(self, user_id: int) -> bool:
user = await self._users.find_by_id(user_id)
if not user:
return False
await self._email.send(user["email"], "Account Deactivated", "Your account has been deactivated.")
return await self._users.delete(user_id)
# Concrete implementations
class PostgresUserRepository:
def __init__(self, pool): self.pool = pool
async def find_by_id(self, user_id: int): ...
async def save(self, user: dict): ...
async def delete(self, user_id: int): ...
class SendgridEmailService:
def __init__(self, api_key: str): self.api_key = api_key
async def send(self, to, subject, body): ...
FastAPI Depends System
FastAPI's Depends() is built-in dependency injection that supports sync and async functions, generators (for cleanup), caching per request, and nested dependencies. It's the most pythonic DI approach for FastAPI applications.
from fastapi import FastAPI, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
import asyncpg
app = FastAPI()
# Simple dependency
async def get_db_pool() -> asyncpg.Pool:
return app.state.db_pool # set in lifespan
async def get_db(pool: asyncpg.Pool = Depends(get_db_pool)) -> asyncpg.Connection:
async with pool.acquire() as conn:
yield conn # cleanup happens automatically after request
# Repository as a dependency
class UserRepo:
def __init__(self, db: asyncpg.Connection):
self.db = db
async def find(self, user_id: int) -> dict | None:
row = await self.db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)
return dict(row) if row else None
def get_user_repo(db=Depends(get_db)) -> UserRepo:
return UserRepo(db)
# Service as a dependency (nesting)
class UserService:
def __init__(self, repo: UserRepo, email: EmailService):
self.repo = repo
self.email = email
def get_email_service() -> EmailService:
return SendgridEmailService(os.environ["SENDGRID_KEY"])
def get_user_service(
repo: UserRepo = Depends(get_user_repo),
email: EmailService = Depends(get_email_service),
) -> UserService:
return UserService(repo, email)
# Routes use the service
@app.get("/users/{user_id}")
async def get_user(user_id: int, svc: UserService = Depends(get_user_service)):
user = await svc.repo.find(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@app.post("/users/{user_id}/deactivate")
async def deactivate(user_id: int, svc: UserService = Depends(get_user_service)):
ok = await svc.deactivate(user_id)
return {"success": ok}
DI Containers: dependency-injector
pip install dependency-injector
from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
# Infrastructure
db_pool = providers.Singleton(
asyncpg.create_pool,
dsn=config.database.url,
min_size=config.database.pool_min,
max_size=config.database.pool_max,
)
redis = providers.Singleton(
aioredis.from_url,
config.redis.url,
)
# Repositories
user_repo = providers.Factory(
PostgresUserRepository,
pool=db_pool,
)
# Services
email_service = providers.Singleton(
SendgridEmailService,
api_key=config.email.sendgrid_key,
)
user_service = providers.Factory(
UserService,
users=user_repo,
email=email_service,
)
# Application entry point
container = Container()
container.config.from_yaml("config.yaml")
container.wire(modules=[__name__])
@app.get("/users/{user_id}")
@inject
async def get_user(
user_id: int,
svc: UserService = Provide[Container.user_service],
):
return await svc.repo.find(user_id)
Testing with DI
import pytest
from unittest.mock import AsyncMock, MagicMock
# Fake implementations for tests
class FakeUserRepository:
def __init__(self):
self._data = {}
self._next_id = 1
async def find_by_id(self, user_id: int) -> dict | None:
return self._data.get(user_id)
async def save(self, user: dict) -> dict:
user["id"] = self._next_id
self._data[self._next_id] = user
self._next_id += 1
return user
async def delete(self, user_id: int) -> bool:
return self._data.pop(user_id, None) is not None
class FakeEmailService:
def __init__(self):
self.sent: list[dict] = []
async def send(self, to, subject, body):
self.sent.append({"to": to, "subject": subject, "body": body})
@pytest.fixture
def user_service():
repo = FakeUserRepository()
email = FakeEmailService()
return UserService(repo, email), repo, email
@pytest.mark.asyncio
async def test_register_sends_welcome_email(user_service):
svc, repo, email = user_service
user = await svc.register("alice", "alice@example.com")
assert user["username"] == "alice"
assert len(email.sent) == 1
assert email.sent[0]["to"] == "alice@example.com"
assert "Welcome" in email.sent[0]["subject"]
# FastAPI override for integration tests
from fastapi.testclient import TestClient
fake_email = FakeEmailService()
def override_get_email_service():
return fake_email
app.dependency_overrides[get_email_service] = override_get_email_service
client = TestClient(app)
def test_deactivate_endpoint():
resp = client.post("/users/1/deactivate")
assert resp.status_code == 200
Common Patterns
# Service Locator (anti-pattern to be aware of)
# Avoid this — hides dependencies, hard to test
class ServiceLocator:
_services = {}
@classmethod
def register(cls, name, instance): cls._services[name] = instance
@classmethod
def get(cls, name): return cls._services[name]
# Factory pattern for creating configured objects
class DatabaseFactory:
@staticmethod
def create(env: str):
if env == "test":
return SQLiteDatabase(":memory:")
elif env == "prod":
return PostgresDatabase(os.environ["DATABASE_URL"])
raise ValueError(f"Unknown env: {env}")
# Scoped dependencies (per-request vs singleton)
# FastAPI: use_cache=True (default) = singleton per request
# use_cache=False = new instance per injection point
@app.get("/data")
async def data(
db1=Depends(get_db, use_cache=True), # same instance
db2=Depends(get_db, use_cache=True), # same instance
db3=Depends(get_db, use_cache=False), # new instance
):
pass
Frequently Asked Questions
- Do I need a DI framework in Python?
- Not for most applications. Constructor injection with Python's duck typing and Protocols is clean and effective without any library. Use a DI framework like
dependency-injectoronly when you have many layers of dependencies with complex lifecycles (singleton vs factory vs scoped). - How is DI different from the Service Locator pattern?
- With DI, dependencies are pushed into the consumer from the outside (explicit). With Service Locator, the consumer pulls dependencies from a central registry (implicit). DI is preferred because dependencies are visible in function signatures — making the code easier to test and understand without reading the implementation.
- How do I handle circular dependencies?
- Circular dependencies indicate a design problem — A depends on B which depends on A. Resolve them by extracting a third component C that both A and B depend on, or by introducing an event/message between them. Don't work around circular deps by delaying imports.