Hexagonal Architecture separated business services from the infrastructure they talk to: services depend only on ports, adapters implement them, and the test suite can swap any adapter in or out.
That separation is half the picture. Somebody still has to instantiate the real adapters, read the configuration, and hand them to the services that need them.
Dependency injection (DI) is the wiring half of the inversion. Constructor injection keeps each class a passive recipient of its dependencies, the same way an aggregate root stays a passive recipient of its entities in Domain-Driven Design. A single Composition Root reads a typed settings object, instantiates each adapter exactly once, and propagates them through the constructors that declare them.
Dependency hardcoupling
When classes instantiate their own dependencies, they assume two distinct responsibilities: business logic execution and resource configuration/instantiation.
Consider this common anti-pattern where a service instantiates its own dependencies directly:
# Anti-pattern: hardcoded dependenciesimport os
class OrderProcessorService: def __init__(self) -> None: # Tightly coupled to a concrete Stripe payment adapter self._payment_gateway = StripePaymentAdapter(api_key=os.environ["STRIPE_API_KEY"])Hardcoded dependencies introduce three critical flaws:
- Untestable in isolation: the service cannot be unit-tested without hitting Stripe or using complex monkeypatching tools.
- Configuration pollution: the service must know configuration details1 such as API keys that are completely separate from its core domain rules.
- Rigid lifecycle management: if the payment adapter needs to share an HTTP connection pool2
like
httpx.AsyncClientwith other classes, sharing becomes difficult because the adapter is instantiated internally.
Classes must remain passive recipients of their dependencies. Instead of reaching out to fetch dependencies, classes must receive instantiated dependencies in their constructors.
Why module-level state fails
The naive fix looks attractive. The easiest approach would be to define the database client and the payment adapter as instances in an adapters module, then import them directly into the services that need them. Construction becomes trivial.
An experienced developer knows this approach does not scale. Three failure modes surface as the project grows, and each one is a debugging trap:
- Test ordering coupling. A test that patches
db.fetch_orderand runs in isolation passes, then fails in CI when a later test imports the same module and inherits the still-patched attribute. Tests must be order-independent; module-level globals make them order-dependent. - Async resource lifecycle. A module-level
httpx.AsyncClientbinds its transport to whichever event loop first uses it.pytest-asynciocreates a fresh loop per test by default, so the client’s transport points at a closed loop on the second test, and every async call raisesRuntimeError: Event loop is closed. - Circular import landmines. A service will eventually raise an
ImportErrorwhen importing different adapters. Module-level state is a dead end. Dependencies must stay local to each instance and be injected at the boundary.
The Hexagonal Architecture post covered the two opposing directions at the core of the inversion: static dependency (imports point inward from adapters to ports) and runtime control (execution calls flow outward from services to adapters). Dependency injection is the mechanism that resolves the architectural conflict at runtime, by having the Composition Root instantiate the adapters and push them into the services that depend on the corresponding ports.
The composition root
To decouple configuration from execution, there are two structural patterns. Constructor dependency injection declares all dependencies as arguments in the constructor (__init__) and types them using protocols. This is the pattern seen in previous posts when declaring protocols. Then there is the composition root, which establishes a single, centralized location in the application startup flow where the bootstrapper instantiates, configures, and wires all dependencies.
The Composition Root is the single function at the entrypoint of the application where the real adapters are instantiated, configured with concrete values from a settings object, and handed to the services that depend on them. Every other module in the codebase stays free of os.environ reads, httpx.AsyncClient() constructors, and PostgresOrderRepository(...) calls; only the Composition Root does that work.
The function takes a typed settings object (covered below), branches on the environment, and returns a container that holds the wired service alongside any resources whose lifecycle the container must manage. The same composition function can be called from a FastAPI lifespan, a Typer CLI, a Celery worker, or a test fixture, and the resulting OrderProcessorService is identical in every case because the wiring is the same.
Use cases and pitfalls
Lifecycle and resource management
When using connection pools3
like httpx.AsyncClient or a database engine , developers must ensure resources are cleanly shut down to avoid leaking connections or close unattended connections.
The Composition Root must act as a context manager to guarantee cleanup during application shutdown or failure. The goal is to centralize the configuration in a typed settings object, not os.getenv calls scattered through the bootstrap:
from pydantic import PostgresDsn, SecretStrfrom pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings): # reads from process env + .env model_config = SettingsConfigDict(env_file=".env", env_nested_delimiter="__")
env: str = "development" database_url: PostgresDsn stripe_api_key: SecretStr http_timeout_seconds: float = 5.0The bootstrap then hands the settings object a single function whose only job is to open the shared resources, wire the adapters, and close the resources again on exit:
from contextlib import asynccontextmanager
class ApplicationContainer: def __init__(self, repository, payment_gateway, http_client=None): # ...
@asynccontextmanagerasync def application_lifecycle(settings: AppSettings): http_client = httpx.AsyncClient(...) # shared pool container = ApplicationContainer( repository=PostgresOrderRepository(...), payment_gateway=StripePaymentAdapter(http_client=http_client, ...), http_client=http_client, ) try: yield container finally: await http_client.aclose()Three concrete wins over the os.getenv version: pydantic-settings reads .env files, validates types (database_url is a PostgresDsn, stripe_api_key is a SecretStr so it does not leak into logs), and fails the process at startup if a required value is missing4
pydantic-settings provides BaseSettings, which reads from process environment variables and .env files, supports nested configuration with env_nested_delimiter, and validates types at construction time. SecretStr masks the value in logs and tracebacks. . The context manager guarantees that http_client.aclose() runs even when the surrounding process crashes.
FastAPI lifespan integration
The container’s lifecycle plugs straight into FastAPI lifespan events:
from contextlib import asynccontextmanagerfrom fastapi import FastAPI
@asynccontextmanagerasync def lifespan(app: FastAPI): settings = AppSettings() async with application_lifecycle(settings=settings) as container: app.state.container = container yieldAdding a second entry point
A FastAPI app is one entry point. A Typer CLI that reuses the same OrderProcessorService is another, and it costs nothing extra to wire if bootstrap_application already takes a settings object:
@cli.command()def process_order(order_id: str) -> None: async def run() -> None: settings = AppSettings(env="production") async with application_lifecycle(settings=settings) as container: result = await container.order_service.execute(order_id=order_id) typer.echo(f"processed={result}") asyncio.run(run())bootstrap_application and OrderProcessorService.execute are the same code as the FastAPI path. Only the wiring and the entry sequence differ.
Test doubles without monkeypatching
Constructor injection lets any port be swapped for a hand-written test double, with no unittest.mock.patch5
like unittest.mock.patch and no module-level state. This is convenient when full control over the double is needed, with no magic involved.
A test for OrderProcessorService only needs to express “given a repository that returns this order, and a gateway that returns success, the service returns true and saves the order”:
The two doubles satisfy the port protocols by structural typing:
from shop.domain import Orderfrom shop.ports import OrderRepositoryPort, PaymentGatewayPort
class FakeOrderRepository(OrderRepositoryPort): def __init__(self) -> None: self.orders: dict[str, Order] = {} self.saved_order: Order | None = None
async def get_by_id(self, order_id: str) -> Order | None: return self.orders.get(order_id)
async def save(self, order: Order) -> None: self.saved_order = order
class FakePaymentGateway(PaymentGatewayPort): def __init__(self, should_succeed: bool) -> None: self.should_succeed = should_succeed
async def charge(self, order: Order) -> bool: return self.should_succeedThe test then expresses the scenario in plain terms:
import pytestfrom shop.domain import Orderfrom shop.services import OrderProcessorService
@pytest.mark.asyncioasync def test_order_processor_success() -> None: repo = FakeOrderRepository() repo.orders["order_123"] = Order(id="order_123", customer_email="buyer@test.com", total_amount=150.00) gateway = FakePaymentGateway(should_succeed=True)
service = OrderProcessorService(repository=repo, payment_gateway=gateway) result = await service.execute(order_id="order_123")
assert result is True assert repo.saved_order is not NoneGlobal singletons
Using module-level variables or singleton classes to access dependencies is a common pitfall. When a service imports a global instance directly, it couples itself to that specific database client state, and that state survives across tests in the same process.
# dbdb_connection = PostgresOrderRepository(connection_string="...")
# servicefrom .db import db_connectionTwo failure modes follow from this, and both are hard and painful to debug. The first is state leakage between tests: a fixture that mutates db_connection leaks into the next test unless the fixture explicitly resets it (a form of “test ordering coupling”). The second is lifecycle confusion with async resources. Constructor injection resolves both by giving each instance its own resources and its own scope.
The DI tool landscape
The dishka alternatives page surveys the current Python DI tooling:
| Tool | What it gives you | Trade-offs |
|---|---|---|
dependency-injector | Mature, Cython-accelerated | No auto-wiring, no per-request scope |
dishka | Strict scopes, fail-fast validation, async finalization, from_context for context data | FastAPI/Celery/Click/aiohttp/Litestar support; overkill for single-entry-point apps |
wireup | Type-driven, FastAPI/Django/Flask/Celery/Click integrations | Newer, smaller community |
svcs | Tiny, explicit “service locator done right” | No autowiring |
FastAPI Depends | Native to FastAPI, Annotated[], dependency_overrides | FastAPI-only |
Each tool has its place, so you’ll have to study their unique characteristics to figure out the best fit for your use case. A Celery worker calls for wireup or dishka (both have official Celery integrations) and saves you from rewriting the composition root. A CLI calls for wireup (Typer and Click coverage out of the box). FastAPI-only setups get a working container from Depends.
In general, the most complete framework I’ve evaluated is dishka, which offers some interesting features:
- Scopes (
APP,REQUEST,ACTION,STEP, plusSESSIONfor WebSockets) let each entry point’s lifetime be a first-class concept. FastAPIDependsonly hasREQUEST; the WebSocket case is hand-rolled. - Fail-fast graph validation runs at
make_container(...)time. A cycle, missing provider, or unsupplied context value raises on the line that imports the providers, not on the first request that exercises the misconfiguration. - Async finalization is handled by
AsyncContainer.__aexit__. A provider declared asasync def get_conn(self) -> AsyncIterator[Connection]: ...has its cleanup awaited when the REQUEST scope exits. from_contextdeclares ambient data (Request,WebSocket, the authenticated user) once viafrom_context(Request, scope=Scope.REQUEST). Middleware supplies it at scope entry, and the framework resolves it in any service constructor without parameter plumbing.Depends(get_request)would thread the value through every service that needs it.
What this pattern costs
A Composition Root earns its overhead for an application with more than one entry point. But that’s not always the case:
- Compose manually for a single entry point with one resource scope. A
@asynccontextmanageraround the FastAPI lifespan covers it. - Adopt a framework when there are multiple entry points with different resource lifecycles.
dishkaandwireupfeed the same provider graph into all entry points; manual composition requires rewritingbootstrap_applicationper entry point. - Instrument the bootstrap in any case. The Composition Root is the single point of failure for misconfiguration (bad
database_url, missingstripe_api_key), so put structured startup logging at the top to find the faulting configuration fast.
The next post explores cross-cutting concerns (request IDs, trace IDs, the authenticated user, the current locale) without bloating the services’ constructors, using the contextvars.ContextVar primitive.
References and additional resources
- FastAPI: dependency injection tutorial, dependencies with
yield - Pydantic: Settings
- Mark Seemann’s blog: Service Locator is an Anti-Pattern (2010), Refactoring to Aggregate Services (2010)
dishka: https://dishka.readthedocs.iosvcs: https://github.com/hynek/svcswireupintegrations: https://maldoinc.github.io/wireup/latest/integrations/