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 chainsget_user_service depends on get_db, which is auto-resolved
  • Lifecycle managementyield dependencies get cleanup (the finally block runs after the response)
  • Caching per request — If multiple endpoints depend on get_db, it’s called once per request
  • Override for testingapp.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.

pythonarchitecturepatterns

See Also