Skip to content
Codeloom
Python

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.

·5 min read · By Codeloom
Intermediate 11 min read

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:

  1. You cannot test place_order without a real SMTP server
  2. 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.