Python dependency injection without a framework explained
Most Python backends start with global imports and module-level singletons, which feel convenient until your codebase grows and testing becomes painful. We'll walk through Python dependency injection without a framework explained—a pattern where you pass dependencies explicitly rather than hunting for them across your codebase. The result is code that's testable, composable, and doesn't require learning another library.
Constructor parameters beat global imports
The foundation of manual dependency injection is straightforward: accept your collaborators as constructor parameters instead of importing them at the module level. This shift makes dependencies visible and testable from the moment you read the class definition.
Consider a payment service that needs a database connection and a logging utility. The anti-pattern looks like this:
# payment_service.py
import logging
from database import get_connection
logger = logging.getLogger(__name__)
db = get_connection()
class PaymentService:
def process(self, order_id):
logger.info(f"Processing order {order_id}")
result = db.query(f"SELECT * FROM orders WHERE id = {order_id}")
# Process payment...
return result
The problem: PaymentService is tightly coupled to global state. Testing requires patching module-level imports, and you can't easily run two instances with different configurations.
Now with constructor injection:
# payment_service.py
class PaymentService:
def __init__(self, db_connection, logger):
self.db = db_connection
self.logger = logger
def process(self, order_id):
self.logger.info(f"Processing order {order_id}")
result = self.db.query(f"SELECT * FROM orders WHERE id = {order_id}")
# Process payment...
return result
Now PaymentService declares what it needs upfront. The class doesn't care where those dependencies come from—a real database, a mock, or a stub. This transparency is the first step toward testable, injectable code.
Functions can take collaborators as arguments
Constructor injection works beautifully for classes, but much Python code is function-oriented. The same principle applies: pass dependencies as function arguments.
Imagine a validation pipeline in an e-commerce API. Without injection:
# validators.py
from fraud_service import fraud_detector
from inventory_service import stock_checker
def validate_order(order_data):
if fraud_detector.is_suspicious(order_data):
return False
if not stock_checker.has_stock(order_data["items"]):
return False
return True
These functions are locked into specific implementations. Testing requires mocking or replacing entire modules.
With explicit parameters:
# validators.py
def validate_order(order_data, fraud_detector, stock_checker):
if fraud_detector.is_suspicious(order_data):
return False
if not stock_checker.has_stock(order_data["items"]):
return False
return True
Now the function's dependencies are visible in the signature. In tests, you pass fakes. In production, you pass the real implementations. The function doesn't orchestrate the dependency lookup—it receives what it needs.
For more complex workflows, you might pass a context object that bundles related services:
# context.py
class ValidationContext:
def __init__(self, fraud_detector, stock_checker, logger):
self.fraud_detector = fraud_detector
self.stock_checker = stock_checker
self.logger = logger
# validators.py
def validate_order(order_data, context):
context.logger.debug(f"Validating order {order_data['id']}")
if context.fraud_detector.is_suspicious(order_data):
context.logger.warning("Fraud detected")
return False
if not context.stock_checker.has_stock(order_data["items"]):
context.logger.warning("Insufficient stock")
return False
return True
This pattern scales well when functions need many collaborators—you avoid parameter bloat while keeping everything explicit.
Wire everything in one composition root
The payoff comes when you create a single place—the composition root—where all dependencies are instantiated and wired together. This is usually called once at application startup and then passed to request handlers, job workers, or tests.
In a typical web service, the composition root lives in your application factory or main entry point:
# app.py
from flask import Flask
from payment_service import PaymentService
from order_service import OrderService
from database import Database
from logger import Logger
def create_app():
# Instantiate shared dependencies
db = Database(host="localhost", port=5432)
logger = Logger(level="INFO")
# Wire them into services
payment_service = PaymentService(db, logger)
order_service = OrderService(db, payment_service, logger)
# Create Flask app and attach services
app = Flask(__name__)
app.services = {
"payment": payment_service,
"order": order_service,
"db": db,
}
# Register routes
@app.route("/orders//pay", methods=["POST"])
def pay_order(order_id):
result = app.services["payment"].process(order_id)
return result
return app
if __name__ == "__main__":
app = create_app()
app.run()
The composition root is the only place you mention concrete classes like Database or Logger. Everywhere else, you work with the interfaces and dependencies passed to you. This concentration makes changes simple: update the database driver once in the composition root, and the entire application adapts.
For async applications or job queues, the pattern remains the same:
# worker.py
from celery import Celery
from payment_service import PaymentService
from database import Database
from logger import Logger
# Create shared dependencies
db = Database(host="localhost", port=5432)
logger = Logger(level="INFO")
payment_service = PaymentService(db, logger)
# Pass to Celery tasks
app = Celery("tasks")
@app.task
def process_payment_async(order_id):
return payment_service.process(order_id)
By centralizing wiring, you make it trivial to swap implementations. Need a different database for read replicas? Update the composition root. Testing a new logging provider? Wire it in once and run your full suite.
Fakes in tests become trivial
The entire point of explicit dependency injection is that tests become dramatically simpler. You don't patch, mock, or monkeypatch—you pass test doubles directly.
Here's a payment service test without injection:
# test_payment_service.py
from unittest.mock import patch
from payment_service import PaymentService
def test_process_payment_insufficient_stock():
with patch("payment_service.fraud_detector") as mock_fraud:
with patch("payment_service.stock_checker") as mock_stock:
mock_fraud.is_suspicious.return_value = False
mock_stock.has_stock.return_value = False
result = PaymentService().process(order_id=123)
assert result is False
This cascades into a maze of patches. With injection, your test is direct:
# test_payment_service.py
from payment_service import PaymentService
class FakeFraudDetector:
def is_suspicious(self, order_data):
return False
class FakeStockChecker:
def has_stock(self, items):
return False
class FakeLogger:
def info(self, msg): pass
def warning(self, msg): pass
def test_process_payment_insufficient_stock():
fake_fraud = FakeFraudDetector()
fake_stock = FakeStockChecker()
fake_logger = FakeLogger()
service = PaymentService(fake_fraud, fake_stock, fake_logger)
result = service.process(order_id=123)
assert result is False
Your test setup is explicit, readable, and requires no knowledge of the service's internal imports. You're testing the behavior, not fighting the framework.
For integration tests, you still use the composition root but with test doubles:
# conftest.py
import pytest
from database import InMemoryDatabase
from logger import NullLogger
from payment_service import PaymentService
@pytest.fixture
def test_services():
db = InMemoryDatabase()
logger = NullLogger()
payment_service = PaymentService(db, logger)
return {
"payment": payment_service,
"db": db,
"logger": logger,
}
# test_integration.py
def test_full_payment_flow(test_services):
test_services["db"].insert("orders", {"id": 1, "total": 100})
result = test_services["payment"].process(1)
assert result is not None
You compose a test environment once with fakes and in-memory databases, then reuse it across your suite. No patching, no surprises.
Common mistakes
- Injecting too much. If every function takes 10 parameters, you've gone too far. Group related dependencies into a context object or service container.
- Creating the composition root inside request handlers. Instantiate dependencies once at startup, not per request. This kills performance and defeats the purpose.
- Mixing injection with global state. If your service still imports global singletons, you haven't gained anything. Be consistent: either inject or import, don't do both.
- Testing without fakes. If you're still mocking external services in tests, you're not reaping the benefits. Write simple in-memory or null implementations.
- Tight coupling to the composition root. Your services shouldn't know or care how they were created. They should only care about the interface of what they receive.
Key takeaways
- Pass dependencies as constructor parameters or function arguments instead of importing them globally.
- Make dependencies explicit in class definitions and function signatures so readers understand what a component needs.
- Create a single composition root at startup where all dependencies are wired together, keeping the rest of your codebase free of instantiation logic.
- Write simple fake implementations for tests—no mocking frameworks needed when dependencies are explicit.
- Manual dependency injection requires discipline but scales well and keeps your codebase flexible and testable without external libraries.






Comments
Loading comments…