Dependency Injection in Python with Dependency Injector: Professional Code Organization for Large Projects

Python tutorial - IT technology blog
Python tutorial - IT technology blog

When Your Code Starts to “Smell” After 3 Months

I once worked on a Python project that processed order data — it started with just 3 modules, written freely and running smoothly. But 3 months later, the project had grown to 20+ modules, and every time I needed to add a new feature, I had to take a deep breath first. The reason: every class was creating its own dependencies internally, like this:

class OrderService:
    def __init__(self):
        self.db = DatabaseConnection(host="localhost", port=5432)  # Hard-coded!
        self.email = EmailSender(smtp_server="smtp.gmail.com")      # Hard-coded!
        self.logger = Logger(level="INFO")                          # Hard-coded!

The result: untestable code (every test required a real running database), impossible to swap out (changing the staging config meant editing 15 different places), and when a senior reviewed the PR… they went quiet and left a comment: “needs refactoring”.

Where Does the Real Problem Lie?

The code still ran correctly — this wasn’t a logic bug. The problem was tight coupling: OrderService didn’t just know how to process orders; it also had to know how to create a database connection, how to connect to SMTP, and how to configure a logger. One class carrying way too much responsibility.

The trap here: for small projects or one-off scripts processing 100K records, this pattern is perfectly fine and no one notices any problem. Issues only surface when the project grows, when you need proper testing, or when a second person joins the team. Specifically:

  • Unit tests: Testing OrderService requires a running database — slow and prone to environment failures
  • Multiple environments: Dev/staging/prod use different configs → you either edit code or scatter env vars everywhere
  • Swapping implementations: Switching from PostgreSQL to MySQL → track down and fix every place that instantiates DatabaseConnection
  • Circular imports: Module A imports module B, B imports A → Python throws an error on load

What Is Dependency Injection — An Explanation for Newcomers

Dependency Injection (DI) is simple: instead of a class creating its own dependencies, those dependencies are passed in from the outside.

# Without DI (bad)
class OrderService:
    def __init__(self):
        self.db = DatabaseConnection()  # Self-instantiated — tightly coupled

# Using DI (good)
class OrderService:
    def __init__(self, db: DatabaseConnection, email: EmailSender):
        self.db = db        # Injected from outside
        self.email = email  # Injected from outside

Now OrderService only needs to know: “I need a db and an email sender.” Who provides them and what kind — that’s not its concern.

Three Approaches, from Rough to Refined

Approach 1: Manual DI — Passing Dependencies by Hand

Manually inject each dependency when creating the object:

db = DatabaseConnection(host=os.getenv("DB_HOST"), port=5432)
email = EmailSender(smtp_server=os.getenv("SMTP_SERVER"))
service = OrderService(db=db, email=email)

Pros: simple, no additional library needed. Cons: as the project grows, this “wiring” section becomes 50 tangled lines in main.py.

Approach 2: Factory / Service Locator

Create a registry to manage object creation, but this is actually the Service Locator pattern — considered an anti-pattern because the class still depends on the factory, just hidden. It makes testing harder, not easier.

Approach 3: The Dependency Injector Library

The dependency-injector library provides a Container system for declaring and wiring dependencies in an explicit, type-safe way that’s easy to override in tests.

Hands-On with Dependency Injector

Installation

pip install dependency-injector

Example Project Structure

myapp/
├── containers.py           # DI Container — declares all dependencies
├── services/
│   ├── order_service.py
│   └── notification_service.py
├── repositories/
│   └── order_repository.py
└── main.py

Step 1 — Write Plain Classes with No Knowledge of DI

# repositories/order_repository.py
class OrderRepository:
    def __init__(self, db_url: str):
        self.db_url = db_url

    def get_order(self, order_id: int) -> dict:
        # query database...
        return {"id": order_id, "status": "pending"}


# services/notification_service.py
class NotificationService:
    def __init__(self, smtp_host: str, smtp_port: int):
        self.smtp_host = smtp_host
        self.smtp_port = smtp_port

    def send_email(self, to: str, subject: str, body: str):
        print(f"[EMAIL] To: {to} | {subject}")


# services/order_service.py
class OrderService:
    def __init__(self, repo: OrderRepository, notifier: NotificationService):
        self.repo = repo
        self.notifier = notifier

    def process_order(self, order_id: int, customer_email: str) -> dict:
        order = self.repo.get_order(order_id)
        self.notifier.send_email(
            to=customer_email,
            subject=f"Order #{order_id} confirmed",
            body="Your order has been processed.",
        )
        return order

Step 2 — Create a Container to Declare All Dependencies

# containers.py
from dependency_injector import containers, providers
from repositories.order_repository import OrderRepository
from services.notification_service import NotificationService
from services.order_service import OrderService


class Container(containers.DeclarativeContainer):

    config = providers.Configuration()

    # Repository — Singleton: created once, reused forever
    order_repository = providers.Singleton(
        OrderRepository,
        db_url=config.database.url,
    )

    # Notification service — Singleton
    notification_service = providers.Singleton(
        NotificationService,
        smtp_host=config.smtp.host,
        smtp_port=config.smtp.port,
    )

    # Order service — Factory: new instance on each call if needed
    order_service = providers.Factory(
        OrderService,
        repo=order_repository,
        notifier=notification_service,
    )

Step 3 — Usage in main.py

# main.py
from containers import Container


def main():
    container = Container()
    container.config.from_dict({
        "database": {"url": "postgresql://user:pass@localhost/mydb"},
        "smtp": {"host": "smtp.gmail.com", "port": 587},
    })

    # Dependencies are automatically injected by the container
    order_service = container.order_service()
    result = order_service.process_order(
        order_id=12345,
        customer_email="[email protected]",
    )
    print(f"Processed order: {result}")


if __name__ == "__main__":
    main()

Singleton vs Factory — Which Should You Choose?

  • providers.Singleton: Created once, reused throughout — use for database connections, config, cache clients. This covers ~80% of real-world use cases.
  • providers.Factory: Creates a new instance on each call — use for request handlers, job processors
  • providers.Resource: Has a clear lifecycle — initialize when needed, clean up when done (file handles, connection pools)

Benefits You Notice Immediately When Writing Tests

Honestly, the reason I use DI isn’t because of clean architecture — it’s because of testing. To test OrderService, you don’t need a real database or an SMTP server:

# tests/test_order_service.py
from unittest.mock import MagicMock
from containers import Container


def test_process_order_sends_notification():
    container = Container()

    mock_repo = MagicMock()
    mock_repo.get_order.return_value = {"id": 1, "status": "pending"}

    mock_notifier = MagicMock()

    # Override dependencies with mocks — clean, no side effects
    with container.order_repository.override(mock_repo):
        with container.notification_service.override(mock_notifier):
            service = container.order_service()
            service.process_order(order_id=1, customer_email="[email protected]")

    mock_notifier.send_email.assert_called_once_with(
        to="[email protected]",
        subject="Order #1 confirmed",
        body="Your order has been processed.",
    )

Tests run in ~5ms instead of 2–3 seconds waiting for a real database. They never fail due to environment issues, and you don’t need to spin up Docker just to run pytest. This is something I wish I had learned earlier back when I was writing hard-coded dependencies.

Integration with FastAPI

With FastAPI, integration only requires adding the @inject decorator:

# api/routes.py
from fastapi import APIRouter, Depends
from dependency_injector.wiring import inject, Provide
from containers import Container
from services.order_service import OrderService

router = APIRouter()


@router.post("/orders/{order_id}/process")
@inject
async def process_order(
    order_id: int,
    customer_email: str,
    order_service: OrderService = Depends(Provide[Container.order_service]),
):
    result = order_service.process_order(order_id, customer_email)
    return {"status": "ok", "order": result}

FastAPI automatically calls the container to get order_service with all its dependencies — no need to pass them manually on each request.

When to Use It, When You Don’t Need It

I don’t suggest using DI for every project. Here’s my rule of thumb after a few years of experience:

  • Use it when: The project has 5+ services, you need to write serious unit tests, the team has 2 or more people, or you need to run across multiple environments (dev/staging/prod)
  • Skip it when: It’s a small script under 200 lines, a one-off tool, or a quick prototype to validate an idea

Over-engineering a 50-line script with a DI Container is a waste of time. But for a backend service running in production for months, not having DI is technical debt you’re digging yourself into.

In Summary

DI isn’t magic — it’s a simple principle: don’t let a class create its own dependencies. The dependency-injector library implements this in a structured way. Declare in the Container, wire automatically, override cleanly in tests.

Is your Python project hard to test, and does every new feature make you anxious? Add DI before the technical debt spreads across the entire codebase — from my experience, this is the refactor step with the highest ROI.

Share: