Dependency Injection

Providing dependencies from outside rather than creating them inside — the key to testable, composable code.

Providing dependencies from outside rather than creating them inside. The key to testable, composable code.


The Core Idea

Without DI (tight coupling):
  class OrderService:
      def __init__(self):
          self.db = PostgresDatabase()       # hardcoded
          self.email = SendGridEmailClient() # hardcoded

With DI (loose coupling):
  class OrderService:
      def __init__(self, db: Database, email: EmailClient):
          self.db = db       # injected from outside
          self.email = email # injected from outside

Benefits:
  - Swap implementations without changing the class (test doubles, stubs)
  - Classes express what they need, not how to get it
  - Inversion of Control: the composition root decides what to wire up
  - Testability: inject fakes in tests, real clients in production

Python Without a Framework

from typing import Protocol

# Define interfaces (Protocols — structural typing, no inheritance needed)
class Database(Protocol):
    async def execute(self, query: str, params: dict) -> list[dict]: ...

class EmailClient(Protocol):
    async def send(self, to: str, subject: str, body: str) -> None: ...

# Domain class depends only on the interfaces
class OrderService:
    def __init__(self, db: Database, email: EmailClient) -> None:
        self._db = db
        self._email = email

    async def place_order(self, user_id: str, product_id: str) -> dict:
        order = await self._db.execute(
            "INSERT INTO orders (user_id, product_id) VALUES (:uid, :pid) RETURNING *",
            {"uid": user_id, "pid": product_id},
        )
        await self._email.send(
            to=f"user+{user_id}@example.com",
            subject="Order confirmed",
            body=f"Your order {order[0]['id']} has been placed.",
        )
        return order[0]

# Composition root — one place that wires everything together
async def create_app() -> OrderService:
    db = AsyncPostgresDatabase(dsn=settings.DATABASE_URL)
    email = SendGridClient(api_key=settings.SENDGRID_KEY)
    return OrderService(db=db, email=email)

# In tests — inject fakes
class FakeDatabase:
    def __init__(self) -> None:
        self.orders: list[dict] = []

    async def execute(self, query: str, params: dict) -> list[dict]:
        row = {"id": "fake-id", **params}
        self.orders.append(row)
        return [row]

class FakeEmailClient:
    def __init__(self) -> None:
        self.sent: list[dict] = []

    async def send(self, to: str, subject: str, body: str) -> None:
        self.sent.append({"to": to, "subject": subject})

async def test_place_order_sends_email():
    db = FakeDatabase()
    email = FakeEmailClient()
    service = OrderService(db=db, email=email)
    await service.place_order("user-1", "prod-abc")
    assert len(email.sent) == 1
    assert "user-1" in email.sent[0]["to"]

FastAPI Dependency Injection

from fastapi import FastAPI, Depends
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker

app = FastAPI()
engine = create_async_engine(settings.DATABASE_URL)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)

# --- Dependency functions ---
async def get_db() -> AsyncSession:
    async with SessionFactory() as session:
        yield session   # FastAPI handles cleanup

async def get_order_repo(db: AsyncSession = Depends(get_db)) -> OrderRepository:
    return OrderRepository(db)

async def get_order_service(
    repo: OrderRepository = Depends(get_order_repo),
    email: EmailClient = Depends(get_email_client),
) -> OrderService:
    return OrderService(repo=repo, email=email)

# --- Route ---
@app.post("/orders")
async def create_order(
    payload: CreateOrderRequest,
    service: OrderService = Depends(get_order_service),
) -> dict:
    return await service.place_order(payload.user_id, payload.product_id)
# Override dependencies in tests — no monkey-patching required
from fastapi.testclient import TestClient

def override_get_db():
    yield fake_session   # from a test fixture

app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)

# async overrides with httpx + pytest-asyncio
from httpx import AsyncClient, ASGITransport

@pytest.fixture
async def client(fake_db_session):
    app.dependency_overrides[get_db] = lambda: fake_db_session
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        yield c
    app.dependency_overrides.clear()

Scoped Dependencies

# FastAPI dependency lifetimes:
#   function scope: new instance per request (default)
#   use_cache=False: new instance per Depends() call within same request
#   global: module-level singleton (dangerous for mutable state)

# Singleton pattern for clients that should not be recreated per request
@lru_cache
def get_settings() -> Settings:
    return Settings()   # reads .env once, reused for all requests

# Request-scoped: database sessions, request contexts
async def get_db() -> AsyncGenerator[AsyncSession, None]:
    async with SessionFactory() as session:
        yield session   # one session per request, closed after response

# Per-call: cheap, stateless helpers
async def get_pagination(page: int = 1, size: int = 20) -> Pagination:
    return Pagination(page=page, size=min(size, 100))

Dependency Injection Containers

# For large applications, use a DI container to manage wiring automatically.
# lagom is a lightweight Python DI container.

from lagom import Container

container = Container()

# Register bindings
container.define(Database, lambda: AsyncPostgresDatabase(settings.DATABASE_URL))
container.define(EmailClient, lambda: SendGridClient(settings.SENDGRID_KEY))

# Container resolves the full dependency graph automatically
order_service = container[OrderService]   # injects Database + EmailClient

# With FastAPI integration
from lagom.integrations.fast_api import FastApiIntegration

integration = FastApiIntegration(container)

@app.post("/orders")
async def create_order(
    payload: CreateOrderRequest,
    service: OrderService = integration.depends(OrderService),
) -> dict:
    return await service.place_order(payload.user_id, payload.product_id)

Common DI Anti-Patterns

Anti-pattern: Service Locator (global registry)
  service = ServiceLocator.get(OrderService)  # hidden dependency
  Problem: dependencies are implicit, testing is harder

Anti-pattern: Constructor over-injection
  class OrderService:
      def __init__(self, db, email, cache, logger, metrics, pubsub, ...):
  Problem: too many dependencies = too many responsibilities (SRP violation)
  Fix: split into smaller services

Anti-pattern: Injecting the container
  class OrderService:
      def __init__(self, container: Container):
          self.db = container.get(Database)
  Problem: circular dependency on the container itself; defeats DI's purpose

Anti-pattern: Mutable shared state in singletons
  global_cache = {}   # shared across requests — race conditions guaranteed
  Fix: use Redis or per-request state

Common Failure Cases

Session leaked across requests (wrong dependency scope) Why: a database session declared as a module-level singleton is shared across concurrent requests, causing one request to see another's uncommitted data or exhaust the connection pool. Detect: intermittent data corruption or DetachedInstanceError under load; single-threaded tests pass cleanly. Fix: declare DB sessions as request-scoped yield dependencies so each request gets its own session that is closed on response.

Circular dependency deadlock Why: Service A depends on Service B, which depends on Service A; the DI container cannot resolve either. Detect: application raises a RecursionError or container resolution error at startup. Fix: introduce an interface (Protocol) that both services depend on, or extract the shared logic into a third, lower-level service that neither depends on the other.

dependency_overrides not cleared between tests Why: a FastAPI override registered in one test leaks into subsequent tests because app.dependency_overrides.clear() is not called in teardown. Detect: tests pass in isolation but fail when run in sequence; failures are non-deterministic. Fix: call app.dependency_overrides.clear() in a yield fixture's teardown block, or use monkeypatch which auto-resets after each test.

Constructor over-injection hiding design problems Why: a class with 8+ injected dependencies is a sign it has too many responsibilities, not a DI configuration problem. Detect: the constructor signature keeps growing; mocking all dependencies in a test requires 10+ lines of setup. Fix: split the class along responsibility boundaries so each resulting class needs 2-4 dependencies.

Connections

cs-fundamentals/se-hub · cs-fundamentals/software-design-principles · cs-fundamentals/tdd-se · cs-fundamentals/clean-code · web-frameworks/fastapi · cs-fundamentals/oop-patterns

Open Questions

  • What are the most common misapplications of this concept in production codebases?
  • When should you explicitly choose not to use this pattern or technique?