Articles

Using a shared IOC container

ALittleMoronSoftware Architecture9 views
ioc container

Introduction

A shared IoC container makes sense in Python services that already have at least some basic separation of concerns: the HTTP layer, business logic, database access, and integrations with external services are kept separate. If this separation does not exist, introducing a shared container is usually premature. It cannot fix a poorly structured project; it only helps assemble components that have already been separated.

As an example, consider a typical GET /items endpoint. The framework receives an HTTP request, parses query parameters, validates the input data, and invokes the handler. The handler should not need to know how to connect to the database, create a storage object, or assemble the entire use case. In a properly layered architecture, it receives the input data, invokes an application action, and returns a response.

In a small service, all of this can be assembled using the framework’s built-in tools. FastAPI provides Depends, Litestar provides Provide, and FastStream has its own DI mechanism. This is a perfectly reasonable approach when the application has a single entry point and the project has not grown significantly yet.

The problem appears later, when the same business logic starts being invoked from different places: an HTTP API, Kafka, a background task, or a CLI command. The logic remains the same, but there are now several different ways to create and replace its dependencies. HTTP uses one DI mechanism, the broker uses another, the CLI uses a third, and tests have their own set of helpers. A shared IoC container is not needed for aesthetic reasons. It is needed to prevent dependency wiring from being scattered across every application entry point.

An important clarification: a shared container does not replace the framework’s built-in mechanisms. In FastAPI, it is still perfectly reasonable to use Query, Path, Body, Depends, and other tools for input data and HTTP contracts. The container is primarily intended for infrastructure and application-level dependencies: use cases, storage implementations, external service clients, transactions, and similar components.

Terminology

A handler, endpoint, or route handler is a function invoked by the framework. It receives input data, calls the application, and returns the result to the outside world. In HTTP, this is a response; in a broker, it is message processing; in a CLI, it is command output or a side effect.

A use case is a specific application action. Examples include retrieving a list of products, creating an order, or clearing a user’s cart after logout. A use case usually does not know whether it was invoked through HTTP, Kafka, or a CLI. It simply performs a business action.

A storage, repository, or gateway is an object that hides the details of interacting with the outside world. For example, ItemsStorage may communicate with PostgreSQL, Redis, or an external API. The use case depends on the storage interface rather than on a specific ORM model or SQL query.

Infrastructure includes databases, brokers, HTTP clients, caches, file storage, configuration, and transactions. These are things that business logic needs in order to operate, but they are not business logic themselves.

The application layer is the code where use cases and the rules for executing application actions live. This layer should generally not know whether a call came from FastAPI, FastStream, or a CLI.

Wiring, or dependency assembly, is the place where the application decides which concrete storage implementation should be passed to a use case, which session should be used, and which external service client should be created. If this logic is scattered across handlers, subscribers, and tests, the project becomes harder to maintain.

DI, or Dependency Injection, means that an object receives its dependencies from the outside. For example, a use case receives ItemsStorage through its constructor instead of creating it internally.

An IoC container is a tool that knows how to assemble a dependency graph: which storage a use case requires, how to create a session, where to obtain the configuration, and when to close connections. DI does not require a container. Everything can be assembled manually using factories. A container simply handles the repetitive parts and dependency lifecycles.

A scope defines the lifetime of a dependency. For example, one object may live for the entire application lifetime, while another should be created for every HTTP request or Kafka message. Scope-related mistakes often lead to unexpected shared state, especially in tests.

The Core Conflict

Framework-level DI is convenient as long as there is only one framework.

As long as the entire service is a FastAPI application, Depends is usually not a problem. It is well integrated with the framework, familiar to the team, and works well in tests through app.dependency_overrides.

However, once FastStream, TaskIQ, a CLI, or another HTTP framework is added, the situation changes. HTTP endpoints use one way to obtain a use case. Kafka subscribers use another. If a CLI command or background job is added later, its dependencies are often assembled manually. Technically, everything still works, but the project now has several different answers to the same question: how should the same use case be created?

This is where things become unpleasant. The application code is supposedly shared, but dependency assembly starts depending on who invokes the use case: HTTP, a broker, a CLI, or a test.

This creates two practical problems:

  1. The API layer starts influencing the application more than it should.
  2. Different entry points require different testing infrastructure.

When It Is Pointless

Before bringing dishka or anydi into a project, it is worth asking honestly: does the problem already exist?

If you need to quickly build a small service, add a couple of endpoints, and avoid spending time on architecture, a shared container will be unnecessary. It will require providers, scopes, integration setup, and separate test containers. In a small project, this may simply be noise.

The same applies when the project is intentionally written as a straightforward CRUD application without layers. You can attach an IoC container to it, but if a handler directly knows about ORM models, external clients, and business rules, the container will not fix anything. It will merely become a more complicated way to create the same tightly coupled objects.

A shared container should not be added “for the future.” This is usually a weak argument, unless the team already has an established container-based project template. A standardized approach may still be better than several inconsistent ones, but in other cases it is better to introduce a container only when dependency wiring has visibly started spreading across the project.

What a Shared Container Provides

A shared container moves dependency assembly into a separate layer. Conceptually, it looks like this:

HTTP handler       \
Kafka handler       -> IoC container -> UseCase -> Storage -> DB session
CLI command        /

The framework remains at the edge of the system. It parses the request or message, validates the input data, invokes a use case, and converts the result into a response. Everything related to creating use cases and infrastructure lives in the container.

You should not try to put absolutely everything into the shared container. A practical boundary looks roughly like this: the framework parses input and assembles request-level parameters, while the IoC container creates infrastructure and application-level dependencies.

This does not mean that replacing FastAPI with Litestar will suddenly become easy. Such migrations are rare, and routing, schemas, middleware, error handling, and some tests would still need to be rewritten. The benefit is more modest and practical: dependency assembly rules will not need to be moved from Depends to Provide, then to FastDepends, and then somewhere else again.

One reasonable option for Python today is dishka. It supports asynchronous code, scopes, lifecycle management and finalization, and provides integrations with FastAPI, Litestar, FastStream, and other frameworks. I will discuss alternative options separately later, but for now, the shared IoC container examples will use this library.

Example: HTTP and Another Framework

The examples below are shortened: imports, application creation, container creation, and the setup_dishka call are omitted. The important part here is the framework integration points rather than the complete project setup. At the same time, the snippets should still correspond to the actual dishka API.

In FastAPI, a handler may look roughly like this:

router = APIRouter(route_class=DishkaRoute)


@router.get("/items")
async def list_items_handler(
    params: Annotated[ListItemsParams, Query()],
    use_case: FromDishka[AbstractListItemsUseCase],
) -> ItemsListSchema:
    items = await use_case.execute(params=params.to_schema())
    return ItemsListSchema.from_schema(schema=items)

The idea is the same in Litestar: the handler receives FromDishka[AbstractListItemsUseCase] instead of assembling the use case through Provide. In the current dishka documentation, the primary Litestar integration approach uses @inject or DishkaRouter for automatic injection:

def build_params(foo: str) -> ListItemsParams:
    return ListItemsParams(foo=foo)


@get(
    "/items",
    dependencies={"params": Provide(build_params)},
)
@inject
async def list_items_handler(
    params: ListItemsParams,
    use_case: FromDishka[AbstractListItemsUseCase],
) -> ItemsListSchema:
    items = await use_case.execute(params=params.to_schema())
    return ItemsListSchema.from_schema(schema=items)

An important detail is that neither example contains code that creates AbstractListItemsUseCase. The handler only knows the use-case contract. The container decides which storage it contains, where the session comes from, and how the connection is closed.

Example: FastAPI + FastStream

FastStream demonstrates the problem particularly well. FastStream resembles FastAPI and can be integrated closely with it through include_router, which makes the issue especially clear. The HTTP and broker layers often share the same business logic but use different DI mechanisms and different override approaches in tests.

Without a shared container, the situation looks roughly like this:

Layer Where dependencies live How to mock
FastAPI Depends, app.dependency_overrides through app.dependency_overrides
FastStream FastDepends / broker provider through a separate provider or broker/router override
CLI / jobs manual factories or separate wiring often only through patching

With a shared container, the table becomes more boring, which is a good thing:

Layer Where dependencies live How to mock
FastAPI shared container test container or container override
FastStream shared container the same test container or override
CLI / jobs shared container the same approach

Dishka provides a FastStream integration. At the time of verification in June 2026, the documentation contains an important note: the FastStream integration has been moved into a separate dishka-faststream package, and the old import path may be removed in the future. This should be taken into account when adding the dependency to a project.

What the Container Looks Like

Providers are best kept separate from the framework. A provider alone does not represent the whole picture. Usually, one module describes how dependencies are assembled, while separate integration points connect the shared container to FastAPI, Litestar, FastStream, or another application entry point.

For example, the shared part may look like this. Imports, application lifecycle management, and container shutdown are omitted to keep the example concise.

class ItemsProvider(Provider):
    @provide(scope=Scope.REQUEST)
    async def provide_storage(
        self,
        session: AsyncSession,
    ) -> ItemsStorage:
        return ItemsDatabaseStorage(session=session)

    @provide(scope=Scope.REQUEST)
    async def provide_list_items_use_case(
        self,
        storage: ItemsStorage,
    ) -> AbstractListItemsUseCase:
        return ListItemsUseCase(storage=storage)


def create_container() -> AsyncContainer:
    return make_async_container(
        DbProvider(),
        ItemsProvider(),
        AuthProvider(),
    )

The important part is not the decorators themselves, but the direction of the dependencies:

  • The API layer depends on the use-case abstraction.
  • The use case depends on the storage abstraction.
  • The concrete ItemsDatabaseStorage appears only in the provider.
  • The framework does not know how these components are assembled.

The same container is then connected to a specific framework.

In FastAPI:

def create_fastapi_app(container: AsyncContainer) -> FastAPI:
    app = FastAPI()
    app.include_router(http_items_router)
    fastapi_integration.setup_dishka(container=container, app=app)
    return app

In Litestar:

def create_litestar_app(container: AsyncContainer) -> Litestar:
    app = Litestar(route_handlers=[litestar_items_router])
    litestar_integration.setup_dishka(container=container, app=app)
    return app

In FastStream:

def create_broker(container: AsyncContainer) -> KafkaBroker:
    broker = KafkaBroker(settings.kafka_url)
    broker.include_router(broker_items_router)
    faststream_integration.setup_dishka(
        container=container,
        broker=broker,
        auto_inject=True,
    )
    return broker

In a real service, the container is often created in a single composition root and then passed into the setup functions for the HTTP application, broker, or background tasks:

container = create_container()

http_app = create_fastapi_app(container)
broker = create_broker(container)

If HTTP and the broker run in the same process, each edge still receives its own setup_dishka call: the FastAPI integration is connected to app, while the FastStream integration is connected to broker. The container itself remains shared.

The example above is intentionally split into separate functions to demonstrate the main idea: the dependency assembly approach remains shared, while framework-specific integration changes only at the edge of the system.

This code is easier to reuse across multiple entry points. HTTP, Kafka, and a CLI can all use the same ItemsProvider if they require the same real dependencies. If another entry point is added later, only an adapter for the new framework needs to be added. There is no need to reinvent the process of creating AbstractListItemsUseCase.

Testing

The most noticeable improvement usually appears in tests.

In FastAPI, the standard way to replace dependencies is app.dependency_overrides. This is perfectly reasonable when the entire service lives inside FastAPI. The FastAPI documentation explicitly recommends this approach for testing.

However, if the project also contains FastStream, a second override system must be maintained. As a result, the testing infrastructure starts mirroring the framework architecture rather than the application architecture.

With a shared container, a test container can be assembled:

class MockItemsProvider(Provider):
    list_items_use_case = provide(MockListItemsUseCase, scope=Scope.APP)
    list_items_use_case_alias = alias(
        source=MockListItemsUseCase,
        provides=AbstractListItemsUseCase,
    )


@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def container() -> AsyncGenerator[AsyncContainer, None]:
    container = make_async_container(
        MockItemsProvider(),
        MockAuthProvider(),
    )
    yield container
    await container.close()

The same container can then be connected to the FastAPI application, broker, or another entry point. Tests receive explicit mock objects from the container:

class ContainerHelper:
    def __init__(self, container: AsyncContainer) -> None:
        self.container = container

    async def get_list_items_use_case(self) -> MockListItemsUseCase:
        return await self.container.get(MockListItemsUseCase)

An API test and a subscriber test can then use the same ContainerFixture. There is no magic involved; it is simply one assembly point instead of several.

Replacing the Entire Container or Individual Dependencies

There are two valid approaches:

  1. Create a separate test container.
  2. Override a specific dependency for a test or a group of tests.

A test container is easier to understand. It works well for API contract tests where almost all use cases are replaced with mocks or stubs.

The disadvantage is obvious: if the entire container is mocked, the test is no longer an integration test. It verifies that the API correctly invokes the use case and converts the result into an HTTP response, but it does not test the real database, transactions, storage implementations, or external clients.

For integration tests, it is better to maintain a second container that is almost real but uses a test database, fake external clients, and separate configuration. Both modes can then coexist in the project:

  • contract tests: a fast mock container;
  • integration tests: a real/test container;
  • use-case unit tests: no container at all, with dependencies passed manually.

The container should not become the mandatory way to test every function. Manual assembly is often clearer for small unit tests.

Disadvantages of a Shared Container

A shared container adds another layer, and developers need to understand that layer. If the project is small and consists of a single FastAPI application without brokers or background tasks, Depends may be simpler and more honest.

The main disadvantages are:

  • The team must define scopes: what lives for the entire application lifetime, what lives for a request or message, and what is created every time.
  • A scope-related mistake may lead to unwanted shared state in tests. For example, a mock registered in Scope.APP must either be reset between tests or recreated.
  • Another library and its integrations are added to the project. They also need to be maintained and updated.
  • Some errors move from explicit code into container configuration: register a provider incorrectly, and dependency graph assembly will fail.
  • Developers need to understand where dependency creation is defined. Providers should therefore remain simple and live in predictable locations.

This is a reasonable price when a service has multiple entry points and a significant amount of repeated infrastructure. In a small project, the price may exceed the benefit.

Alternatives

Besides dishka, there are other DI and IoC libraries, including dependency-injector, injector, di, punq, lagom, anydi, and others. Dishka’s documentation includes a separate page comparing alternative solutions.

The argument should not be phrased as “dishka is the most popular.” Such a claim becomes outdated easily and does not help much when choosing a tool. It is better to evaluate specific properties:

  • support for asynchronous factories;
  • scopes and lifecycle management/finalization;
  • proper support for request or message context;
  • integrations with the required frameworks;
  • a clear override mechanism for tests;
  • no global container that is difficult to replace in parallel tests.

AnyDI also appears to be a viable option. It previously had fewer integrations, but it now declares support for FastAPI, Django/Django Ninja, FastStream, Typer, and Pydantic Settings. It may be worth considering for a smaller project that would benefit from a simpler API.

For the use case described above, however, dishka is a good fit specifically because of its combination of scopes, asynchronous lifecycle management, integrations with FastAPI, Litestar, and FastStream, and a clear provider model.

When to Use a Shared Container

A shared IoC container is worth adding when:

  • the service has both HTTP and a broker or consumer;
  • the same business logic is invoked through multiple entry points;
  • tests already contain different helpers for overriding dependencies;
  • the project has a clear separation between API, core/use cases, and database/infrastructure layers;
  • dependency creation and lifecycle management should be kept separate from framework-specific code.

It is probably not worth adding when:

  • the service is small and exists only inside FastAPI;
  • the goal is to build a service very quickly without focusing on architectural cleanliness;
  • there are few dependencies and they are not duplicated across entry points;
  • the team is not prepared to manage scopes and lifecycles;
  • the container is needed only “for the future,” without an existing problem.

Conclusion

A shared IoC container does not automatically make an application cleaner. It simply moves dependency assembly out of frameworks and into a separate place. If layer boundaries already exist, this is usually helpful: the API remains thin, use cases do not know about FastAPI or FastStream, and tests use a single mechanism for replacing dependencies.

If those boundaries do not exist, the container may only introduce another layer of confusion. Dishka and AnyDI should therefore be treated pragmatically: they are tools for projects that are already experiencing real problems caused by multiple DI mechanisms.