Dependency Injection in Python Without a Framework
Learn how to apply dependency injection in Python using constructor injection, factory functions, and simple containers -- no framework required.
What you'll learn
- ✓What dependency injection is and why it improves testability
- ✓Implementing constructor injection in plain Python
- ✓Building a lightweight DI container in under 50 lines
- ✓When to use DI and when it is overkill
Prerequisites
None — this post is self-contained.
Dependency injection means passing dependencies to a component instead of having the component create them. This simple idea makes code more testable, more flexible, and easier to reason about. In languages like Java or C#, DI typically requires a framework. In Python, you can achieve the same benefits with constructor arguments and a few patterns.
The Problem: Hard-Coded Dependencies
Consider a service that sends notifications:
import smtplib
class OrderService:
def place_order(self, order: dict) -> None:
# Process the order
self._save_to_database(order)
# Hard-coded dependency -- impossible to test without an SMTP server
server = smtplib.SMTP("smtp.company.com", 587)
server.send_message(
from_addr="orders@company.com",
to_addrs=order["email"],
msg=f"Order {order['id']} confirmed",
)
This code has two problems:
- You cannot test
place_orderwithout a real SMTP server - You cannot switch to a different notification channel (SMS, Slack) without modifying the class
Constructor Injection
The fix is straightforward: pass the dependency through the constructor.
from typing import Protocol
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None: ...
class EmailNotifier:
def __init__(self, host: str, port: int):
self._host = host
self._port = port
def send(self, recipient: str, message: str) -> None:
import smtplib
with smtplib.SMTP(self._host, self._port) as server:
server.sendmail("orders@company.com", recipient, message)
class OrderService:
def __init__(self, notifier: Notifier):
self._notifier = notifier
def place_order(self, order: dict) -> None:
self._save_to_database(order)
self._notifier.send(
order["email"],
f"Order {order['id']} confirmed",
)
def _save_to_database(self, order: dict) -> None:
...
Now testing is trivial:
class FakeNotifier:
def __init__(self):
self.messages: list[tuple[str, str]] = []
def send(self, recipient: str, message: str) -> None:
self.messages.append((recipient, message))
def test_place_order_sends_notification():
notifier = FakeNotifier()
service = OrderService(notifier=notifier)
service.place_order({"id": "ORD-1", "email": "user@test.com"})
assert len(notifier.messages) == 1
assert notifier.messages[0][0] == "user@test.com"
assert "ORD-1" in notifier.messages[0][1]
No mocking library needed. The fake is a plain class that records calls.
Multiple Dependencies
Real services typically need several dependencies:
from typing import Protocol
class Repository(Protocol):
def save(self, entity: dict) -> None: ...
def get(self, id: str) -> dict: ...
class Logger(Protocol):
def info(self, message: str) -> None: ...
def error(self, message: str) -> None: ...
class PaymentGateway(Protocol):
def charge(self, amount: float, token: str) -> dict: ...
class OrderService:
def __init__(
self,
repo: Repository,
notifier: Notifier,
payments: PaymentGateway,
logger: Logger,
):
self._repo = repo
self._notifier = notifier
self._payments = payments
self._logger = logger
def place_order(self, order: dict) -> dict:
self._logger.info(f"Processing order {order['id']}")
result = self._payments.charge(order["total"], order["payment_token"])
if result["status"] != "success":
self._logger.error(f"Payment failed for {order['id']}")
raise ValueError("Payment failed")
self._repo.save(order)
self._notifier.send(order["email"], f"Order {order['id']} confirmed")
return result
Factory Functions for Wiring
As the number of services grows, you need a place to wire everything together. A factory function keeps this organized:
def create_order_service(config: dict) -> OrderService:
repo = PostgresRepository(
host=config["db_host"],
port=config["db_port"],
database=config["db_name"],
)
notifier = EmailNotifier(
host=config["smtp_host"],
port=config["smtp_port"],
)
payments = StripeGateway(api_key=config["stripe_key"])
logger = StructuredLogger(level=config.get("log_level", "INFO"))
return OrderService(
repo=repo,
notifier=notifier,
payments=payments,
logger=logger,
)
Your application entry point calls the factory:
def main():
config = load_config()
order_service = create_order_service(config)
# Use order_service...
A Lightweight DI Container
For larger applications, a simple container can manage dependency creation and lifetime:
from typing import Any, Callable, TypeVar
T = TypeVar("T")
class Container:
def __init__(self):
self._factories: dict[type, Callable] = {}
self._singletons: dict[type, Any] = {}
self._singleton_types: set[type] = set()
def register(self, interface: type, factory: Callable, singleton: bool = False) -> None:
self._factories[interface] = factory
if singleton:
self._singleton_types.add(interface)
def resolve(self, interface: type[T]) -> T:
if interface in self._singletons:
return self._singletons[interface]
factory = self._factories.get(interface)
if factory is None:
raise KeyError(f"No registration for {interface}")
instance = factory(self)
if interface in self._singleton_types:
self._singletons[interface] = instance
return instance
Using the Container
container = Container()
# Register implementations
container.register(
Repository,
lambda c: PostgresRepository(host="localhost", port=5432, database="app"),
singleton=True,
)
container.register(
Notifier,
lambda c: EmailNotifier(host="smtp.company.com", port=587),
)
container.register(
PaymentGateway,
lambda c: StripeGateway(api_key="sk_test_123"),
)
container.register(
Logger,
lambda c: StructuredLogger(level="INFO"),
singleton=True,
)
container.register(
OrderService,
lambda c: OrderService(
repo=c.resolve(Repository),
notifier=c.resolve(Notifier),
payments=c.resolve(PaymentGateway),
logger=c.resolve(Logger),
),
)
# Resolve the fully-wired service
order_service = container.resolve(OrderService)
For testing, register fakes:
test_container = Container()
test_container.register(Repository, lambda c: InMemoryRepository())
test_container.register(Notifier, lambda c: FakeNotifier())
test_container.register(PaymentGateway, lambda c: FakePaymentGateway())
test_container.register(Logger, lambda c: NullLogger())
test_container.register(
OrderService,
lambda c: OrderService(
repo=c.resolve(Repository),
notifier=c.resolve(Notifier),
payments=c.resolve(PaymentGateway),
logger=c.resolve(Logger),
),
)
When DI Is Overkill
Not everything needs injection. Good candidates for DI:
- External services (databases, APIs, message queues)
- Configuration that varies between environments
- Components you want to test in isolation
Poor candidates for DI:
- Pure utility functions with no side effects
- Standard library usage (
json.dumps,pathlib.Path) - Internal implementation details that callers should not control
A rule of thumb: if a dependency crosses a boundary (network, filesystem, external process), inject it. If it is a pure computation, call it directly.
Key Takeaways
Dependency injection in Python does not require a framework. Constructor injection with Protocols gives you testability and flexibility. Factory functions keep wiring organized. For larger applications, a simple container (under 50 lines) manages creation and lifetime. The goal is not to inject everything — it is to inject the things that make testing and configuration hard when they are hard-coded.
Related articles
- Python Python Testing with pytest: Fixtures, Marks, and Plugins
Master pytest from fixtures and parametrize to marks, plugins, and CI integration. Write fast, maintainable Python tests with practical examples.
- Python Python Mocking with unittest.mock: Complete Guide
Master Python's unittest.mock library for testing. Learn Mock, MagicMock, patch, side_effect, and spec with real-world examples for unit testing.
- Python Python Design Patterns: Strategy, Factory, and Observer
Learn how to implement the Strategy, Factory, and Observer design patterns in Python using idiomatic constructs like first-class functions and protocols.
- Python Advanced pytest Patterns for Real-World Projects
Master pytest fixtures, parametrize, monkeypatch, and custom markers to write maintainable, expressive tests for production Python code.