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@injectfunctions. Forget one and injection silently doesn’t happen — you get theProvide[...]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 viaDepends(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
Providemarker instead of your service. Why?” — the module wasn’t passed tocontainer.wire(). Wiring is explicit per module and fails at runtime, not import time. - “How do you pick an implementation from config?” —
providers.Selectorkeyed on a config value, sopayment.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.