Skip to main content
Technology & EngineeringPython Patterns262 lines

Dependency Injection

Dependency injection patterns for loosely coupled, testable Python applications

Quick Summary18 lines
You are an expert in dependency injection patterns for writing loosely coupled, testable Python code.

## Key Points

- **Constructor injection** passes dependencies via `__init__` parameters.
- **Function/method injection** passes dependencies as function arguments.
- **Protocol-based interfaces** define contracts without inheritance, enabling duck-typed DI.
- **DI containers** (e.g., `dependency-injector`, `inject`, `punq`) automate wiring and lifecycle management.
- **Factory functions** and **callables** act as lightweight providers.
- **Scopes** control dependency lifecycle: singleton, per-request, transient.
- Start with constructor injection — it is explicit, requires no framework, and works with any test runner.
- Define dependencies as `Protocol` types rather than concrete classes to keep coupling loose.
- Use factory functions for complex object graphs; reach for a DI container only when manual wiring becomes unwieldy.
- Keep the composition root (where everything is wired together) in one place, typically near the application entry point.
- Prefer `yield`-based dependencies (as in FastAPI) for resources that need cleanup.
- Make dependencies explicit in function signatures rather than importing globals — this makes testing trivial.
skilldb get python-patterns-skills/Dependency InjectionFull skill: 262 lines
Paste into your CLAUDE.md or agent config

Dependency Injection — Python Patterns

You are an expert in dependency injection patterns for writing loosely coupled, testable Python code.

Overview

Dependency injection (DI) inverts control by passing dependencies into a component rather than having the component create them. In Python, DI is often achieved through constructor arguments, function parameters, or lightweight DI containers — without the heavy frameworks common in Java. The result is code that is easier to test, reconfigure, and extend.

Core Philosophy

Dependency injection is about making your code honest about what it needs. When a function or class declares its dependencies as explicit parameters rather than importing and constructing them internally, it tells you exactly what it requires to do its job. This honesty is the foundation of testability — you can swap any dependency with a fake, a mock, or an alternative implementation simply by passing a different argument.

In Python, DI should be lightweight and idiomatic. You do not need a Java-style framework with XML configuration files and annotation scanning. Constructor parameters, function arguments, and Python's Protocol-based structural typing give you everything you need. A DI container is useful when your object graph grows complex enough that manual wiring becomes tedious, but you should be able to understand and debug your wiring without the container.

The composition root pattern is the architectural discipline that holds DI together. All your wiring — which concrete classes fulfill which interfaces — should happen in one place, typically near your application's entry point. Business logic modules should never import concrete implementations of their dependencies; they should only know about the Protocol or abstract interface. This separation means you can reconfigure your entire application (swap a database, use a different email provider, switch to an in-memory cache for testing) by changing only the composition root.

Core Concepts

  • Constructor injection passes dependencies via __init__ parameters.
  • Function/method injection passes dependencies as function arguments.
  • Protocol-based interfaces define contracts without inheritance, enabling duck-typed DI.
  • DI containers (e.g., dependency-injector, inject, punq) automate wiring and lifecycle management.
  • Factory functions and callables act as lightweight providers.
  • Scopes control dependency lifecycle: singleton, per-request, transient.

Implementation Patterns

Constructor injection (the simplest pattern)

from typing import Protocol

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

class SmtpEmailSender:
    def __init__(self, host: str, port: int):
        self.host = host
        self.port = port

    def send(self, to: str, subject: str, body: str) -> None:
        print(f"Sending via SMTP {self.host}:{self.port} to {to}")

class UserService:
    def __init__(self, email_sender: EmailSender):
        self.email_sender = email_sender

    def register(self, email: str, name: str) -> None:
        # ... create user ...
        self.email_sender.send(
            to=email,
            subject="Welcome",
            body=f"Hello {name}!"
        )

# Production wiring
sender = SmtpEmailSender("smtp.example.com", 587)
service = UserService(email_sender=sender)

# Test wiring
class FakeEmailSender:
    def __init__(self):
        self.sent: list[tuple] = []
    def send(self, to, subject, body):
        self.sent.append((to, subject, body))

fake = FakeEmailSender()
test_service = UserService(email_sender=fake)
test_service.register("test@example.com", "Test")
assert len(fake.sent) == 1

Function injection with defaults

from typing import Callable

def fetch_data(
    url: str,
    http_get: Callable[[str], bytes] = None,
) -> dict:
    if http_get is None:
        import urllib.request
        http_get = lambda u: urllib.request.urlopen(u).read()
    raw = http_get(url)
    return parse(raw)

# In tests
def mock_get(url: str) -> bytes:
    return b'{"key": "value"}'

result = fetch_data("https://api.example.com", http_get=mock_get)

Factory pattern for complex construction

from dataclasses import dataclass
from typing import Protocol

class Database(Protocol):
    def query(self, sql: str) -> list[dict]: ...

class PostgresDB:
    def __init__(self, dsn: str):
        self.dsn = dsn
    def query(self, sql: str) -> list[dict]:
        ...

class SQLiteDB:
    def __init__(self, path: str):
        self.path = path
    def query(self, sql: str) -> list[dict]:
        ...

def create_database(config: dict) -> Database:
    if config["type"] == "postgres":
        return PostgresDB(dsn=config["dsn"])
    elif config["type"] == "sqlite":
        return SQLiteDB(path=config["path"])
    raise ValueError(f"Unknown database type: {config['type']}")

Lightweight DI container

from typing import Any, TypeVar, Type

T = TypeVar("T")

class Container:
    def __init__(self):
        self._factories: dict[type, Any] = {}
        self._singletons: dict[type, Any] = {}

    def register(self, interface: type, factory, singleton: bool = False):
        self._factories[interface] = (factory, singleton)

    def resolve(self, interface: Type[T]) -> T:
        if interface in self._singletons:
            return self._singletons[interface]

        factory, is_singleton = self._factories[interface]
        instance = factory(self)

        if is_singleton:
            self._singletons[interface] = instance
        return instance

# Usage
container = Container()
container.register(EmailSender, lambda c: SmtpEmailSender("smtp.example.com", 587), singleton=True)
container.register(Database, lambda c: PostgresDB("postgresql://localhost/app"), singleton=True)
container.register(UserService, lambda c: UserService(email_sender=c.resolve(EmailSender)))

service = container.resolve(UserService)

FastAPI-style dependency injection

from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session

app = FastAPI()

def get_db() -> Session:
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

def get_user_repo(db: Session = Depends(get_db)) -> UserRepository:
    return UserRepository(db)

@app.get("/users/{user_id}")
def read_user(
    user_id: int,
    repo: UserRepository = Depends(get_user_repo),
):
    return repo.get(user_id)

dependency-injector library

from dependency_injector import containers, providers

class Container(containers.DeclarativeContainer):
    config = providers.Configuration()

    email_sender = providers.Singleton(
        SmtpEmailSender,
        host=config.smtp.host,
        port=config.smtp.port,
    )

    user_service = providers.Factory(
        UserService,
        email_sender=email_sender,
    )

container = Container()
container.config.from_dict({"smtp": {"host": "smtp.example.com", "port": 587}})

service = container.user_service()

Testing with DI: overriding dependencies

import pytest

@pytest.fixture
def fake_sender():
    return FakeEmailSender()

@pytest.fixture
def user_service(fake_sender):
    return UserService(email_sender=fake_sender)

def test_register_sends_welcome_email(user_service, fake_sender):
    user_service.register("alice@example.com", "Alice")
    assert len(fake_sender.sent) == 1
    assert fake_sender.sent[0][1] == "Welcome"

Best Practices

  • Start with constructor injection — it is explicit, requires no framework, and works with any test runner.
  • Define dependencies as Protocol types rather than concrete classes to keep coupling loose.
  • Use factory functions for complex object graphs; reach for a DI container only when manual wiring becomes unwieldy.
  • Keep the composition root (where everything is wired together) in one place, typically near the application entry point.
  • Prefer yield-based dependencies (as in FastAPI) for resources that need cleanup.
  • Make dependencies explicit in function signatures rather than importing globals — this makes testing trivial.

Common Pitfalls

  • Service Locator antipattern — injecting the container itself and calling container.resolve() throughout business logic hides dependencies and makes code harder to understand.
  • Over-abstracting — not every dependency needs an interface; inject concrete classes when there is only one implementation and testing does not require a fake.
  • Circular dependencies — if A depends on B and B depends on A, restructure by extracting a shared interface or mediator.
  • Forgetting cleanup — injected resources (DB connections, file handles) still need lifecycle management; use context managers or framework-provided scopes.
  • Too many constructor parameters — if a class needs more than 5-6 dependencies, it likely has too many responsibilities; split it.

Anti-Patterns

  • Service locator disguised as DI — passing the entire container into business classes and calling container.resolve(SomeService) throughout the code. This hides actual dependencies, makes them impossible to discover from the constructor signature, and turns every class into a tightly coupled consumer of the container itself.

  • Importing concrete implementations in business logic — writing from app.services.smtp import SmtpEmailSender inside a service module instead of depending on an EmailSender Protocol. This hardcodes the dependency and makes it impossible to swap without modifying the consuming code.

  • Interface explosion — creating a Protocol for every single dependency, including trivial helpers and pure functions that will never have an alternative implementation. This adds indirection without value. Only abstract dependencies that genuinely vary between environments (production, testing, staging).

  • Scattered wiring — constructing dependencies in multiple places throughout the codebase rather than centralizing it in a composition root. This makes it impossible to understand what implementation is being used where, and changing a dependency requires hunting through dozens of files.

  • Ignoring dependency lifecycles — treating all dependencies as singletons when some should be per-request (database sessions, HTTP clients with auth tokens) or transient (stateful processors). Mismatched lifecycles cause stale data, connection leaks, and concurrency bugs.

Install this skill directly: skilldb add python-patterns-skills

Get CLI access →