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: we cannot unit-test the service 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 because nothing has to be passed around.
An experienced developer knows this approach does not scale. Three failure modes surface as the project grows:
- 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. It’s only a matter of time until a service produces an
ImportErrorwhen importing different adapters.
Module-level state is a dead end for modularity. 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: the Composition Root instantiates adapters and pushes them into the services that depend on the corresponding ports.
The Composition Root
To decouple configuration from execution, we have two structural patterns. Constructor dependency injection declares all dependencies as arguments in the constructor (__init__) and types them using protocols. This is what we’ve seen in previous posts, when declaring protocols. Then we have 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.
Uses 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. We want 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 missing9
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 you swap any port for a hand-written test double, with no unittest.mock.patch5
like unittest.mock.patch and no module-level state. This can be convienent if you need full control over the double, 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”:
import pytestfrom shop.domain import Orderfrom shop.ports import OrderRepositoryPort, PaymentGatewayPortfrom shop.services import OrderProcessorService
# Hand-written doubles satisfy the port protocols by structural typing.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_succeed
@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_connectionAs mentioned in “Why Module-Level State Fails”, two failure modes follow, and both quite 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.
When to Transition to a DI Framework
For small and medium-sized projects, manual composition in a bootstrap_application() function is preferred because the process uses standard Python, remains easy to debug, and provides clear stack tracebacks. Moving to a DI framework might be worth if:
- A service has more than three or four constructor parameters. Mark Seemann’s “Facade Service” pattern8 Mark Seemann, “Refactoring to Aggregate Services” (2010). Introduces the Facade Service pattern as the response to “too many constructor parameters.” The threshold that matters is per-service parameter count, not the total number of services. treats this as the trigger to refactor: extract a cluster of collaborators into a typed facade, then inject the facade.
- A second entry point appears. Once you have two
bootstrap_*functions, the manual composition has stopped paying for itself, and a container that both entry points can share is worth the extra dependency. - Cross-cutting concerns need to reach many services. A logger, a tracer, a request ID, a clock, feature flags. Injecting these into every service constructor is the textbook “constructor over-injection” smell. The fix is not a container; it is
contextvars.ContextVar10 PEP 567 (Python 3.7+) introducedcontextvarsfor per-task context propagation acrossawaitboundaries.structlog,opentelemetry, and modern web frameworks use it for request-scoped metadata. or a middleware.
We’ll explore existing frameworks, what do they offer, and what does the production cost look like in the next post.
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)