Why I reached for dependency-injector in production Python

There's a moment in every growing Python codebase where the wiring stops being elegant. Here's what `dependency-injector` actually solved for me — concrete problems, not theory.

There's a moment in the life of every growing Python project where the wiring stops being elegant.

You started with a clean RAGService class. It needed an LLM client, so you instantiated one in __init__. Then you needed a vector store, so you added that too. Then a Redis cache for conversation history. Then a Postgres pool for analytics. By the time you're three months in, your service constructors are juggling six dependencies, your main.py looks like a tangle of from x import client lines, and writing a unit test means monkey-patching half a dozen module attributes.

This is the moment I reached for dependency-injector. This post is about what it actually solved — not in theory, but in the specific, annoying ways my code was bad before, and the specific ways it got better.

The pain, made concrete

Here's the kind of code I was writing before:

# rag_service.py
class RAGService:
    def __init__(self):
        self.llm = AsyncOpenAI(api_key=os.environ["OPENAI_KEY"])
        self.search = SearchClient(
            endpoint=os.environ["SEARCH_ENDPOINT"],
            credential=AzureKeyCredential(os.environ["SEARCH_KEY"]),
        )
        self.redis = redis.Redis.from_url(os.environ["REDIS_URL"])
        self.pg = psycopg.connect(os.environ["DATABASE_URL"])

This code is bad in at least four distinct ways, and they all bite you at different times:

  1. Every RAGService() opens new connections. If two parts of the app construct one, you've doubled your connection count. The fix is usually to hoist these into module-level globals, which creates its own mess.
  2. Configuration is sourced from inside the class. The class can't be reused in a context where OPENAI_KEY lives somewhere else. It's also impossible to look at the class and understand what it actually depends on without reading the body.
  3. Testing is miserable. To test the routing logic of RAGService, you have to mock out the OpenAI client, the search client, the Redis client, and the Postgres connection — all of which are instantiated inside the constructor you're trying to test. The standard playbook becomes @patch("rag_service.AsyncOpenAI") decorators stacked four deep.
  4. You can't swap implementations. Want to run the same service against a different LLM provider in your eval suite? Want to point at a staging vector index instead of prod? You're editing source code or smuggling logic through environment variables and if/else ladders.

The honest answer to most of these problems is "dependency injection" — the principle that objects should receive their dependencies rather than create them. But once you commit to the principle, you also commit to the bookkeeping. Something has to construct all those dependencies in the right order, at the right time, with the right configuration. That something is what dependency-injector is.

What dependency-injector actually does

At its core, the library gives you a container — a declarative description of every object your app needs, and how each one is built from the others.

from dependency_injector import containers, providers
from openai import AsyncOpenAI
import redis.asyncio as redis

class Container(containers.DeclarativeContainer):
    config = providers.Configuration()

    llm_client = providers.Singleton(
        AsyncOpenAI,
        api_key=config.openai.api_key,
    )

    redis_client = providers.Singleton(
        redis.Redis.from_url,
        url=config.redis.url,
    )

    rag_service = providers.Factory(
        RAGService,
        llm=llm_client,
        cache=redis_client,
    )

Three things are happening here that are worth slowing down on.

First, dependencies are declared, not constructed. llm_client isn't an AsyncOpenAI instance — it's a recipe for one. The instance doesn't exist until something asks for it.

Second, the graph is composable. rag_service doesn't know how to build an AsyncOpenAI; it just declares that it needs one, and the container wires it up. Add a new dependency two levels deep and nothing else changes.

Third, the provider type matters. Singleton means "build this once, hand out the same instance forever." Factory means "build a new one every time." That distinction is the entire game when it comes to lifecycle management, and getting it right by hand is exactly where global-singleton code falls apart.

Wiring it into your app

Declaring a container is half the story. The other half is connecting it to the code that actually runs — your FastAPI endpoints, background workers, scripts. There are two patterns, and they compose.

Pattern 1 — ask the container directly

When you control the call site (an entry point, a CLI command, a test setup), just call the provider:

container = Container()
container.config.from_yaml("config/base.yaml")

rag = container.rag_service()        # fully-wired RAGService
result = await rag.evaluate(claim)

This is the "I'm in charge here" path. No decorators, no wiring step, no magic. Most useful for scripts, migrations, and the top of main.py.

Pattern 2 — @inject for code that's called by something else

FastAPI routes, Celery tasks, async workers — these are called by a framework, not by you. There's no spot where you could pass the container in, so you mark dependencies inline in the signature itself:

# endpoints/score.py
from fastapi import APIRouter, Depends
from dependency_injector.wiring import inject, Provide

from ..containers import Container
from ..services import RAGService
from ..schemas import Claim

router = APIRouter()

@router.post("/score")
@inject
async def score(
    claim: Claim,
    rag: RAGService = Depends(Provide[Container.rag_service]),
):
    return await rag.evaluate(claim)

Three things are doing work in that signature:

  • Provide[Container.rag_service] is a marker — "give me whatever the container's rag_service provider returns." At import time it's a placeholder; at call time it gets replaced.
  • Depends(...) is FastAPI's standard "this is a dependency, resolve it before calling me." (Without FastAPI, you'd skip Depends and write Provide[...] directly as the default value.)
  • @inject is what makes the resolution actually happen. Without it, you'd get a Provide object passed in literally — which is rarely what you want.

The function reads like documentation: this endpoint needs a RAGService. No global imports, no hidden state.

The wiring step

@inject doesn't work by itself. The container has to know which modules to scan for Provide[] markers, otherwise the markers just sit there as inert objects. You tell it once at startup:

# main.py
from fastapi import FastAPI

from .containers import Container
from .endpoints import score, health
from .services import rag

def create_app() -> FastAPI:
    container = Container()
    container.config.from_yaml("config/base.yaml")
    container.config.openai.api_key.from_env("OPENAI_API_KEY")

    # Tell the container which modules to walk for @inject sites.
    container.wire(modules=[score, health, rag])

    app = FastAPI()
    app.container = container  # keep a reference for shutdown
    app.include_router(score.router)
    app.include_router(health.router)
    return app

container.wire(modules=[...]) walks each listed module, finds every Provide[] marker, and rewrites the function defaults so they resolve through the container at call time. After wire() returns, calling score(claim) from a route handler — or from a test, or from anywhere — triggers the container to construct (or fetch the cached) rag_service and pass it in.

For larger apps, packages=["myapp.endpoints", "myapp.services"] is the lazy version: it recurses the whole package tree so you don't have to list every module by hand.

Cleanup at shutdown

If you declared Resource providers (like the Postgres pool earlier), the container owns their teardown. With FastAPI's lifespan:

@app.on_event("shutdown")
async def shutdown() -> None:
    await app.container.shutdown_resources()

That's the full lifecycle in one place: the container builds resources on first demand, hands them out to wired functions and direct callers, and cleans them up at app exit. Nothing is global, but everything is shared correctly.

What it solved, problem by problem

Problem 1: Singleton lifecycle, done right

Most expensive clients in a modern Python app — LLM clients, database pools, HTTP sessions, vector store clients — hold internal state you want to share. An AsyncOpenAI instance maintains connection pools. A psycopg pool, well, is a pool. Creating a new one per request silently kills your throughput.

The naive fix is a module-level global:

# clients.py
llm = AsyncOpenAI(api_key=os.environ["OPENAI_KEY"])

This works until it doesn't. Import-time side effects break tests. Initialization order becomes implicit. You can't have two configurations side-by-side. And if OPENAI_KEY isn't set when this module is imported (which happens in CI, in scripts, in docs builds), your whole app crashes at import.

Singleton providers give you the same "one instance, shared" semantics, but with explicit construction time, explicit configuration, and the ability to spin up a fresh container for tests:

llm_client = providers.Singleton(
    AsyncOpenAI,
    api_key=config.openai.api_key,
)

The instance is created the first time container.llm_client() is called. Before that, it doesn't exist. Tests can build a container with different config and get a different singleton. There's no global state, only managed state.

For things that need explicit async setup and teardown — connection pools being the classic case — there's a Resource provider:

async def init_pg_pool(dsn: str):
    pool = await asyncpg.create_pool(dsn)
    yield pool
    await pool.close()

class Container(containers.DeclarativeContainer):
    config = providers.Configuration()
    pg_pool = providers.Resource(init_pg_pool, dsn=config.postgres.dsn)

The container handles init_resources() and shutdown_resources() calls, so your pool gets cleaned up properly when the app shuts down. No atexit hacks, no "did I remember to close this?"

Problem 2: Tests that don't fight you

This is the win that converted me. Here's what testing looked like before:

@patch("rag_service.AsyncOpenAI")
@patch("rag_service.SearchClient")
@patch("rag_service.redis.Redis")
def test_rag_routes_oos_query(mock_redis, mock_search, mock_llm):
    mock_llm.return_value.chat.completions.create.return_value = ...
    service = RAGService()
    # ...

Three decorators, all tightly coupled to the import paths inside rag_service. Refactor the module structure and every test breaks.

Here's what it looks like with the container:

def test_rag_routes_oos_query():
    container = Container()
    container.config.from_dict(test_config)

    fake_llm = AsyncMock()
    fake_llm.chat.completions.create.return_value = make_oos_response()

    with container.llm_client.override(fake_llm):
        service = container.rag_service()
        result = await service.run("what's the weather")
        assert result.routed_to == "out_of_scope"

The override happens at the container level. Anything in the graph that depends on llm_client — directly, or transitively through five layers of services — gets the fake. You're not patching imports; you're swapping a node in the dependency graph. That's both more correct and more maintainable.

You can also override a whole subtree:

with container.retriever.override(providers.Singleton(StubRetriever, docs=fixture_docs)):
    # The entire RAG agent now uses the stub retriever
    agent = container.rag_agent()

This is the thing I want to emphasize most: tests stop being about mocking the right import path and start being about describing the system under test. That's a meaningful quality improvement.

Problem 3: Configuration that scales beyond one environment

Real applications run in more than one place. Local dev, CI, staging, prod — same code, different configs. The Configuration provider handles this with layered loading:

container = Container()
container.config.from_yaml("config/base.yaml")
container.config.from_yaml(f"config/{ENV}.yaml")  # overrides
container.config.openai.api_key.from_env("OPENAI_API_KEY")

Every value flows through the container, which means there's a single, inspectable place where "what is this app configured with?" gets answered. No more grep-ing for os.environ across forty files.

Problem 4: Swapping implementations cleanly

This one matters for anyone working on AI systems where you might want to swap LLM providers, vector backends, or embedding models without changing application code. The Selector provider picks a backend at runtime based on a config key:

llm_client = providers.Selector(
    config.llm.provider,
    openai=providers.Singleton(AsyncOpenAI, api_key=config.openai.api_key),
    anthropic=providers.Singleton(AsyncAnthropic, api_key=config.anthropic.api_key),
    fake=providers.Singleton(DeterministicFakeLLM, responses=config.llm.fake_responses),
)

Flip llm.provider: fake in your eval config and the whole app now runs deterministically with no network calls. Flip it to anthropic and you've A/B-tested two providers without touching application code. The dependency is an interface; the implementation is a configuration choice.

Why this counts as "good practice" in production

You can build a production app without a DI framework. People do it every day. So why does this approach earn the "good practice" label?

A few reasons that I've come to believe through experience rather than theory:

The dependency graph becomes an artifact you can read. When everything is wired through a container, the container is the architecture diagram. Someone new on the team can open containers.py and understand what the system is made of in five minutes. That's not true of an app where dependencies are hidden inside constructors.

Lifecycle is explicit, not emergent. "When does the database pool get created? When does it get closed? Is it shared across requests?" These questions have answers you can point to in code, not answers that depend on Python import order.

Configuration has exactly one path through the app. Values enter through the container, flow through providers, and arrive where they're needed. There's no os.environ.get scattered across business logic, no constants modules with mysterious provenance.

Tests are about behavior, not plumbing. Once you can override any node in the graph with one line, the cost of writing a test drops, and the number of tests you write goes up. That compounds over the life of a project.

Swapping things is a config change, not a refactor. This sounds minor until you need to do it under deadline. Moving from OpenAI to Azure OpenAI, or from one vector store to another, or from real services to fakes for an offline demo — these are all hours of work, not weeks, when the system is wired this way.

When it's overkill

I should be honest: this is not a free lunch. For a single-file script, a small notebook, or a one-week prototype, dependency-injector is more ceremony than it's worth. The break-even point is roughly:

  • More than a handful of stateful dependencies (clients, pools, sessions)
  • More than one environment to run in
  • A test suite that needs to mock external services
  • More than one person on the codebase

Below that line, plain function arguments and a few module-level objects will serve you fine. Above it, the structure pays for itself within weeks.

Two real downsides worth naming: the @inject + Provide[...] wiring magic does some import-time inspection that can be confusing the first time you see a stack trace from it, and type checkers (mypy, pyright) sometimes need a hand with the Provide[...] markers in endpoint signatures. Neither is a dealbreaker, but they're real.

The bottom line

dependency-injector didn't change what my code does. It changed where the complexity lives. Instead of being scattered across constructors, global modules, and test fixtures, the complexity of "what does this app need, how is it built, and how do its pieces fit together" lives in one declarative file.

That sounds like a small win, but it's the kind of small win that determines whether a codebase is pleasant to work in two years from now. The first time you swap out an LLM provider with a config change, or write a test without a single @patch decorator, or hand the project to a new engineer who actually understands its architecture by lunchtime — that's when you realize the framework is doing real work.

If you're building anything beyond a prototype in Python — especially anything with stateful clients and external dependencies (which is to say, almost everything in modern AI engineering) — it's worth the investment.