backend / architecture design / 07_di_pattern_library.md

DI containers and the dependency-injector library

7 interview angles 5 min read source

DI containers and the dependency-injector library

Dependency injection as a pattern is in 03_dependency_injection.md. This file is about containers — and specifically about dependency-injector, the library most Python projects mean when they say “we use a DI container”.

Why a container at all

Manual wiring is fine until it isn’t:

# composition root, by hand - works, and grows
def build_app():
    config = load_config()
    engine = create_engine(config.db_url)
    session_factory = sessionmaker(engine)
    cache = Redis.from_url(config.redis_url)
    user_repo = UserRepository(session_factory)
    payment = StripeGateway(config.stripe_key, cache)
    order_service = OrderService(user_repo, payment, cache)
    ...

At twenty services this is a long function where the order matters, lifetimes are implicit, and swapping one implementation for tests means threading a parameter through several layers.

A container declares the graph instead of building it, and manages lifetimes — the part manual wiring handles worst.

dependency-injector in practice

from dependency_injector import containers, providers
from dependency_injector.wiring import Provide, inject

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

    # Singleton: created once, reused for the process lifetime
    db_engine = providers.Singleton(create_engine, url=config.db.url)
    redis = providers.Singleton(Redis.from_url, config.redis.url)

    # Factory: a new instance every time it's requested
    session = providers.Factory(sessionmaker, bind=db_engine)

    user_repo = providers.Factory(UserRepository, session_factory=session)

    # Selector: pick an implementation from config
    payment_gateway = providers.Selector(
        config.payment.provider,
        stripe=providers.Singleton(StripeGateway, api_key=config.stripe.key),
        adyen=providers.Singleton(AdyenGateway, api_key=config.adyen.key),
    )

    order_service = providers.Factory(
        OrderService,
        repo=user_repo,
        payments=payment_gateway,
        cache=redis,
    )

The declaration is the dependency graph. Nothing constructs anything until it’s requested.

The provider types that matter

Provider Lifetime Use for
Singleton one per container engines, clients, connection pools
Factory new every call request-scoped objects, services holding state
Resource init + teardown anything needing cleanup — pools, sessions
Configuration config from env, YAML, dict, pydantic
Selector choose implementation by config value
Object a pre-built instance
Callable wrap a plain function

Resource is the one people miss. It’s the provider with a teardown phase, which is what you need for anything holding a connection:

def init_db_pool(url: str):
    pool = create_async_pool(url)
    yield pool                 # everything before yield = setup
    pool.close()               # everything after = teardown

class Container(containers.DeclarativeContainer):
    db_pool = providers.Resource(init_db_pool, url=config.db.url)

# lifecycle
container.init_resources()      # on startup
await container.shutdown_resources()   # on shutdown

Without Resource, singletons never get closed and you leak connections on shutdown.

Wiring into FastAPI

The pattern the library is usually used with:

from fastapi import Depends, FastAPI

@app.post("/orders")
@inject
async def create_order(
    payload: OrderIn,
    service: OrderService = Depends(Provide[Container.order_service]),
):
    return await service.create(payload)

def create_app() -> FastAPI:
    container = Container()
    container.config.from_pydantic(Settings())
    container.wire(modules=[__name__, "app.api.orders"])   # inject into these modules

    app = FastAPI()
    app.container = container          # keep a reference or it gets GC'd
    return app

Two details that cause real bugs:

  • wire() must list every module containing @inject functions. Forget one and injection silently doesn’t happen — you get the Provide[...] marker object instead of your service, and the failure looks like an attribute error deep in the call.
  • Keep a reference to the container on the app. Otherwise it can be garbage collected and providers break.

Overriding for tests

This is the main payoff, and the thing to demonstrate in an interview:

def test_create_order():
    container = Container()
    container.config.from_dict(TEST_CONFIG)

    with container.payment_gateway.override(providers.Object(FakeGateway())):
        service = container.order_service()
        result = service.create(payload)
        assert result.status == "pending"
    # override is reverted on exit

You replace one node in the graph and everything depending on it gets the fake, without touching the code under test or threading a parameter through five layers.

container.override(OtherContainer()) swaps a whole set at once — useful for an integration-test container.

Depends() or a container?

The question you’ll actually be asked if the project uses both.

FastAPI Depends() dependency-injector
Scope per request, framework-bound application-wide, framework-agnostic
Lifetimes request-scoped, cached per request singleton / factory / resource, explicit
Where it lives in route signatures in a container module
Works outside HTTP no — not in Celery tasks, CLI, workers yes
Config-driven swapping manual Selector

The decisive argument for a container is code that isn’t an HTTP request. Celery tasks, CLI commands, Kafka consumers and scheduled jobs all need the same object graph, and Depends() doesn’t reach them. A project with workers alongside an API ends up wiring things twice unless it has a container.

They compose: the container owns the graph, Depends(Provide[...]) is how a route asks for a node.

Trade-offs, honestly

  • Indirection. “Where does this instance come from” becomes a container lookup rather than a call site. Navigation in an IDE gets worse.
  • Runtime wiring errors, not import-time. A missing wire() fails when the endpoint runs.
  • Another concept for new joiners.
  • Overkill for a flat graph. Three services and one database do not need a container.

Use it when the graph is deep, when lifetimes genuinely differ, when the same graph is needed outside HTTP, or when implementations are selected by config.

Interview angle

  • “What is a DI container and why use one over manual wiring?” — it declares the object graph rather than constructing it, and manages lifetimes explicitly. Manual wiring is fine until the graph is deep or the same graph is needed from workers as well as routes.
  • “Singleton vs Factory vs Resource?” — Singleton is one instance per container for engines and clients; Factory creates a new instance per request for stateful services; Resource adds a teardown phase, which is what connection pools and sessions need so they close on shutdown.
  • “You already have FastAPI Depends(). Why add a container?”Depends() is request-scoped and framework-bound, so it can’t wire a Celery task, a CLI command or a Kafka consumer. A container owns one graph usable everywhere, and routes can still consume it via Depends(Provide[...]).
  • “How do you swap an implementation in tests?”container.<provider>.override(providers.Object(fake)), scoped with a context manager so it reverts. Everything depending on that node gets the fake without changing production code.
  • “Injection isn’t happening and you get a Provide marker instead of your service. Why?” — the module wasn’t passed to container.wire(). Wiring is explicit per module and fails at runtime, not import time.
  • “How do you pick an implementation from config?”providers.Selector keyed on a config value, so payment.provider = "stripe" in the environment chooses the gateway with no code change.
  • “When would you not use one?” — a flat graph in an HTTP-only service. The indirection and runtime wiring errors cost more than the wiring it saves.