Skip to content
ExponaBLOG
PYTHON BACKEND DEVELOPMENT · DAY 097 / 100

Python dependency injection without a framework explained

Pass dependencies explicitly.

Expona Admin7 min read

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:

python

# 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:

python

# 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:

python

# 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:

python

# 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:

python

# 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:

python

# 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:

python

# 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:

python

# 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:

python

# 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:

python

# 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles