Introduction
Dependency Injection is a design principle where a class receives its dependencies from external sources rather than creating them internally. This pattern is fundamental to writing modular, testable, and maintainable Python applications. DI implements the broader Inversion of Control (IoC) principle: instead of a class controlling its own dependencies, control is inverted to the caller.
Key Concepts
- Dependency: Any object that another object needs to function (a database connection, an API client, a logger)
- Injection: The act of providing a dependency to a class from outside
- Inversion of Control (IoC): The principle that high-level modules should not depend on low-level modules; both should depend on abstractions
- Composition Root: The single place in your application where all dependencies are wired together
Real World Context
Consider a web application with a UserService that needs a database and an email sender. Without DI, the service creates its own database connection and email client, making it impossible to test without a real database or email server. With DI, you pass these dependencies in, allowing you to substitute fakes during testing and swap implementations in production.
Deep Dive
Hard-Coded Dependencies (Problematic)
pythonclass UserService: def __init__(self): self.db = PostgresDatabase() # Hard dependency self.mailer = SmtpMailer() # Hard dependency def create_user(self, name: str) -> User: user = User(name=name) self.db.save(user) self.mailer.send_welcome(user.email) return user
This is problematic because:
- Cannot test without a real Postgres database
- Cannot test without an SMTP server
- Cannot swap to a different database or mailer
Injected Dependencies (Better)
pythonfrom typing import Protocol class Database(Protocol): def save(self, entity: object) -> None: ... class Mailer(Protocol): def send_welcome(self, email: str) -> None: ... class UserService: def __init__(self, db: Database, mailer: Mailer): self.db = db self.mailer = mailer def create_user(self, name: str, email: str) -> dict: user = {"name": name, "email": email} self.db.save(user) self.mailer.send_welcome(email) return user
Using Annotated for DI Frameworks
Python 3.9+ Annotated is often used by DI frameworks (like FastAPI or specialized libraries) to attach metadata to types.
pythonfrom typing import Annotated from fastapi import Depends def get_db() -> Database: return PostgresDatabase() def handler(db: Annotated[Database, Depends(get_db)]): db.save(entity)
Common Pitfalls
- Over-injection: Injecting too many dependencies into a single class is a sign it has too many responsibilities. If a class needs more than 4-5 dependencies, consider splitting it.
- Service Locator anti-pattern: Passing a container/registry and having the class look up its own dependencies defeats the purpose of DI. Dependencies should be explicit in the constructor signature.
- Ignoring Protocols: Injecting concrete classes instead of Protocols (interfaces) reduces flexibility. Always depend on abstractions.
Best Practices
- Depend on abstractions: Use Protocols to define what your dependencies must provide
- Inject through constructors: Make dependencies required and explicit
- Wire at the composition root: Configure all dependencies in one place (e.g., your main module or a factory function)
- Keep constructors simple: Constructors should only assign dependencies, not perform logic
Summary
Dependency Injection decouples classes from their dependencies by passing them in rather than creating them internally. Combined with Python's Protocol system, DI enables testable, flexible code where implementations can be swapped without modifying the dependent class. The key insight is: a class should declare what it needs, not how to create it.
Code Examples
from typing import Protocol
class Logger(Protocol):
def log(self, message: str) -> None: ...
class ConsoleLogger:
def log(self, message: str) -> None:
print(f"[LOG] {message}")
class FileLogger:
def __init__(self, path: str) -> None:
self.path = path
def log(self, message: str) -> None:
with open(self.path, "a") as f:
f.write(f"{message}\n")
class OrderService:
def __init__(self, logger: Logger) -> None:
self.logger = logger
def place_order(self, item: str) -> None:
self.logger.log(f"Order placed: {item}")
# Production: use FileLogger
service = OrderService(FileLogger("/var/log/orders.log"))
service.place_order("Widget")
# Testing: use ConsoleLogger (or a Mock)
test_service = OrderService(ConsoleLogger())
test_service.place_order("Test Widget")from typing import Annotated
# Annotated attaches metadata to type hints
type APIKey = Annotated[str, "header-auth"]
type PositiveInt = Annotated[int, "must be > 0"]
def authenticate(key: APIKey) -> bool:
return len(key) > 0
def set_quantity(qty: PositiveInt) -> None:
print(f"Setting quantity to {qty}")
# Frameworks like FastAPI read the metadata
# to auto-inject dependencies at runtime