Статьи

Использование общего IOC-контейнера

ALittleMoronАрхитектура ПО9 просмотров
IOC-контейнер

Вступление

Общий IoC-контейнер имеет смысл обсуждать в Python-сервисах, где уже есть хотя бы минимальное разделение кода: отдельно HTTP-слой, отдельно бизнес-логика, отдельно работа с базой и внешними сервисами. Если такого разделения нет, то общий контейнер обычно появляется слишком рано. Он не спасает плохую структуру проекта, а только помогает аккуратнее собрать уже выделенные части.

Для примера можно представить обычную ручку GET /items. Фреймворк принимает HTTP-запрос, разбирает query-параметры, валидирует входные данные и вызывает handler. Handler не должен сам знать, как подключиться к базе, как создать storage и как собрать весь use-case. В нормальной слоистой схеме он берет входные данные, вызывает действие приложения и возвращает response.

В маленьком сервисе все это можно собрать средствами самого фреймворка. В FastAPI для этого есть Depends, в Litestar - Provide, в FastStream - своя DI-механика. Это нормальный путь, если вход в приложение один и проект не успел разрастись.

Проблема появляется позже, когда одна и та же бизнес-логика начинает вызываться из разных мест: HTTP API, Kafka, фоновой задачи, CLI-команды. Логика одна, а способов создать и подменить зависимости становится несколько. В HTTP один DI-механизм, в broker'е второй, в CLI третий, в тестах еще отдельный набор helper'ов. Общий IoC-контейнер нужен не для красоты, а чтобы эту сборку зависимостей не размазывать по всем входам в приложение.

Сразу важная оговорка: общий контейнер не отменяет встроенные механизмы фреймворка. В FastAPI все еще нормально использовать Query, Path, Body, Depends и другие инструменты для входных данных и HTTP-контракта. Контейнер нужен в первую очередь для инфраструктуры и зависимостей уровня приложения: use-case'ов, storage, клиентов внешних сервисов, транзакций и похожих вещей.

Термины

Handler, endpoint, ручка - функция, которую вызывает фреймворк. Она принимает входные данные, дергает приложение и возвращает результат наружу. В HTTP это response, в broker'е это обработка сообщения, в CLI это вывод команды или side effect.

Use-case - конкретное действие приложения. Например: получить список товаров, создать заказ, очистить корзины пользователя после logout. Use-case обычно не знает, пришел запрос из HTTP, Kafka или CLI. Он просто выполняет бизнес-действие.

Storage, repository, gateway - объект, который прячет детали работы с внешним миром. Например, ItemsStorage может ходить в PostgreSQL, Redis или внешний API. Use-case зависит от интерфейса storage, а не от конкретной ORM-модели или SQL-запроса.

Инфраструктура - база данных, брокер, HTTP-клиенты, кэш, файловое хранилище, конфиг, транзакции. Это то, что нужно бизнес-логике для работы, но не является самой бизнес-логикой.

Слой приложения / application layer - код, где живут use-case'ы и правила выполнения действий. Этот слой обычно не должен знать, пришел вызов из FastAPI, FastStream или CLI.

Wiring / сборка зависимостей - место, где решается, какой конкретный storage передать в use-case, какую session использовать, какой клиент внешнего сервиса создать. Если это место размазано по handlers, subscribers и тестам, проект становится неприятнее поддерживать.

DI (Dependency Injection) - это когда объект получает зависимости снаружи. Например, use-case получает ItemsStorage через конструктор, а не создает его внутри себя.

IoC-контейнер - это инструмент, который знает, как собрать граф зависимостей: какой storage нужен use-case'у, как создать session, где взять config, когда закрывать соединения. DI не обязан идти через контейнер. Можно собрать все руками через фабрики. Контейнер просто забирает на себя рутинную часть и lifecycle.

Scope - время жизни зависимости. Например, один объект может жить все время работы приложения, а другой должен создаваться на каждый HTTP-запрос или Kafka-сообщение. Ошибки в scope часто приводят к странному shared state, особенно в тестах.

Центральный конфликт

Фреймворковый DI удобен, пока фреймворк один.

Пока весь сервис - это FastAPI-приложение, Depends обычно не мешает. Он хорошо интегрирован во фреймворк, понятен команде, нормально работает в тестах через app.dependency_overrides.

Но если рядом появляется FastStream, TaskIQ, CLI или другой HTTP-фреймворк, ситуация меняется. У HTTP-ручек один способ получить use-case. У Kafka-subscriber'ов другой. Если потом появляется CLI-команда или background job, там зависимости часто собираются руками. Формально все работает, но в проекте появляются несколько разных ответов на один и тот же вопрос: как создать один и тот же use-case.

Это и есть неприятное место. Код приложения вроде бы один, а сборка зависимостей начинает зависеть от того, кто вызывает use-case: HTTP, broker, CLI или тест.

Из-за этого появляются две практические проблемы:

  1. API-слой начинает сильнее влиять на приложение, чем хотелось бы.
  2. Для разных входов приходится писать разную инфраструктуру тестирования.

Когда это бессмысленно

Перед тем как тащить dishka/anydi в проект, стоит честно спросить себя: а боль уже есть?

Если нужно быстро поднять маленький сервис, накидать пару ручек и не заниматься архитектурой, общий контейнер будет лишним. Он потребует провайдеры, scopes, setup интеграций, отдельные тестовые контейнеры. Для маленького проекта это может быть просто шум.

То же самое, если проект осознанно пишется как простой CRUD без слоев. Можно сколько угодно прикручивать IoC-контейнер, но если handler напрямую знает про ORM-модели, внешние клиенты и бизнес-условия, контейнер не спасет. Он будет просто более сложным способом создавать те же самые связанные объекты.

Не стоит добавлять общий контейнер "на будущее". Обычно это плохой аргумент, только если у вас в команде не предусмотрен шаблон с контейнером. Такой общий подход будет всё равно лучше разрозненного подхода, но в других случаях лучше добавить его тогда, когда уже видно, что wiring начал расползаться по проекту.

Что дает общий контейнер

Общий контейнер переносит сборку зависимостей в отдельный слой. Условно:

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

Фреймворк остается на краю системы. Он парсит request/message, валидирует входные данные, вызывает use-case и превращает результат в response. Все, что связано с созданием use-case'ов и инфраструктуры, лежит в контейнере.

Не нужно пытаться запихнуть в общий контейнер вообще все. Практичная граница примерно такая: фреймворк разбирает вход и собирает request-level параметры, а IoC-контейнер создает инфраструктуру и зависимости уровня приложения.

Это не значит, что потом можно будет легко заменить FastAPI на Litestar. Такое редко происходит, и все равно придется переписать роутинг, схемы, middleware, обработку ошибок и часть тестов. Польза скромнее и практичнее: правила сборки зависимостей не придется переносить из Depends в Provide, потом в FastDepends, потом еще куда-нибудь.

Для Python сейчас один из нормальных вариантов - dishka. У нее есть async support, scopes, lifecycle/finalization и готовые интеграции с FastAPI, Litestar, FastStream и другими фреймворками. Далее я отдельно поговорю про другие варианты, но пока условимся, что примеры общего IOC-контейнера будут на этой библиотеке.

Пример: HTTP и другой фреймворк

Примеры ниже сокращены: в них опущены импорты, создание приложения, создание контейнера и вызов setup_dishka. Здесь важны точки интеграции с фреймворком, а не полный setup проекта. При этом сами куски должны соответствовать реальному API dishka.

В FastAPI handler может выглядеть примерно так:

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)

В Litestar идея такая же: handler получает FromDishka[AbstractListItemsUseCase], а не собирает use-case через Provide. В текущей документации dishka для Litestar основной вариант - @inject или DishkaRouter для автоинъекции:

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)

Важная деталь: в этих примерах нет кода создания AbstractListItemsUseCase. Handler знает только контракт use-case'а. Какой storage у него внутри, откуда берется session и как закрывается соединение - решает контейнер.

Пример: FastAPI + FastStream

FastStream хорошо показывает проблему. FastStream похож на FastAPI, имеет возможность тесно интегрироваться с ним через include_router, поэтому проблема тут наиболее показательная. У HTTP и broker-слоя часто одна бизнес-логика, но разные механизмы DI и разные способы override в тестах.

Без общего контейнера получается примерно так:

Слой Где живут зависимости Как мокать
FastAPI Depends, app.dependency_overrides через app.dependency_overrides
FastStream FastDepends / provider broker'а через отдельный provider или override broker/router
CLI / jobs ручные фабрики или отдельный wiring зачастую только через patch

С общим контейнером таблица становится скучнее, и это хорошо:

Слой Где живут зависимости Как мокать
FastAPI общий контейнер тестовый контейнер или override контейнера
FastStream общий контейнер тот же тестовый контейнер или override
CLI / jobs общий контейнер тот же подход

У dishka есть интеграция с FastStream. На момент проверки в июне 2026 года в документации есть важное замечание: интеграцию FastStream вынесли в отдельный пакет dishka-faststream, старый путь в будущем могут удалить. Это стоит учитывать при добавлении зависимости в проект.

Как выглядит контейнер

Провайдеры лучше держать отдельно от фреймворка. Сам по себе provider - это еще не вся картина. Обычно есть отдельный модуль, где описывается, как собирать зависимости, и отдельные места, где этот общий контейнер подключается к FastAPI, Litestar, FastStream или другому входу.

Например, общая часть может выглядеть так. Импорты, lifecycle приложения и закрытие контейнера здесь опущены, чтобы не раздувать пример.

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(),
    )

Тут важны не декораторы сами по себе, а направление зависимостей:

  • API-слой зависит от абстракции use-case'а.
  • Use-case зависит от абстракции storage.
  • Конкретный ItemsDatabaseStorage появляется только в провайдере.
  • Фреймворк не знает, как все это собирается.

Дальше этот же контейнер подключается к конкретному фреймворку.

В 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

В 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

В 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

В реальном сервисе контейнер часто создается в одном composition root и потом передается в setup HTTP-приложения, broker'а или фоновых задач:

container = create_container()

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

Если HTTP и broker живут в одном процессе, для каждого края все равно вызывается свой setup_dishka: FastAPI-интеграция подключается к app, FastStream-интеграция - к broker. Контейнер при этом остается один.

Пример выше специально разделен по функциям, чтобы было видно главное: подход к сборке зависимостей остается общим, а интеграция меняется только на краю системы.

Такой код легче переиспользовать между разными входами. HTTP, Kafka и CLI могут использовать один и тот же ItemsProvider, если им нужны одинаковые реальные зависимости. Если завтра появится еще один вход, нужно будет добавить только адаптер к новому фреймворку, а не заново придумывать, как создать AbstractListItemsUseCase.

Тестирование

Самое ощутимое улучшение обычно появляется в тестах.

В FastAPI стандартный способ подмены зависимостей - app.dependency_overrides. Он нормальный, если весь сервис живет в FastAPI. Документация FastAPI прямо предлагает этот путь для тестов.

Но если в проекте есть еще FastStream, приходится держать вторую систему override. В результате тестовая инфраструктура начинает повторять архитектуру фреймворков, а не архитектуру приложения.

С общим контейнером можно собрать тестовый контейнер:

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()

Дальше тот же контейнер подключается к FastAPI app, broker'у или другому входу. Тесты получают понятные mock-объекты из контейнера:

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)

Тогда API-тест и subscriber-тест могут использовать один и тот же ContainerFixture. Это не магия, просто одна точка сборки вместо нескольких.

Подменять весь контейнер или отдельные зависимости

Есть два рабочих подхода:

  1. Собрать отдельный тестовый контейнер.
  2. Использовать override конкретной зависимости для теста или группы тестов.

Тестовый контейнер проще понять. Он хорошо подходит для контрактных API-тестов, где почти все use-case'ы заменяются моками или стабами.

Минус очевидный: если контейнер целиком моковый, это уже не интеграционный тест. Он проверяет, что API правильно вызывает use-case и правильно превращает результат в HTTP response, но не проверяет реальную базу, транзакции, storage и внешние клиенты.

Для интеграционных тестов лучше держать второй контейнер: почти реальный, но с тестовой БД, fake external clients и отдельным config. Тогда в проекте могут жить оба режима:

  • контрактные тесты: быстрый mock container;
  • интеграционные тесты: real/test container;
  • unit-тесты use-case'ов: вообще без контейнера, зависимости передаются руками.

Контейнер не должен стать обязательным способом протестировать каждую функцию. Для маленьких unit-тестов ручная сборка часто понятнее.

Минусы общего контейнера

Общий контейнер добавляет отдельный слой, и этот слой нужно понимать. Если проект маленький, один FastAPI app без broker'ов и фоновых задач, то Depends может быть проще и честнее.

Основные минусы:

  • Нужно договориться о scopes: что живет на уровне приложения, что на уровне request/message, что создается каждый раз.
  • Ошибка в scope может привести к неприятному shared state в тестах. Например, мок в Scope.APP нужно чистить между тестами или создавать заново.
  • Появляется еще одна библиотека и ее интеграции. Их тоже придется обновлять.
  • Часть ошибок переезжает из явного кода в конфигурацию контейнера: неправильно зарегистрировал provider - получил ошибку сборки графа.
  • Разработчикам нужно понимать, где искать создание зависимости. Для этого providers должны быть простыми и лежать в ожидаемом месте.

Это нормальная цена, если у сервиса несколько входов и много повторяемой инфраструктуры. Если проект маленький, цена может быть выше пользы.

Альтернативы

Кроме dishka есть и другие DI/IoC-библиотеки: dependency-injector, injector, di, punq, lagom, anydi и другие. У dishka в документации есть отдельная страница со сравнением альтернатив.

Не стоит формулировать аргумент как "dishka самая популярная". Это легко устаревает и не очень помогает выбрать инструмент. Лучше смотреть на конкретные свойства:

  • поддержка async factories;
  • scopes и lifecycle/finalization;
  • нормальная работа с request/message context;
  • интеграции с нужными фреймворками;
  • понятный override для тестов;
  • отсутствие глобального контейнера, который сложно заменить в параллельных тестах.

AnyDI тоже выглядит живым вариантом. Раньше у него было меньше интеграций, но сейчас заявлены FastAPI, Django/Django Ninja, FastStream, Typer и Pydantic Settings. Если проект небольшой и хочется более простого API, его можно рассмотреть.

Но для описанного выше кейса dishka хорошо подходит именно из-за связки: scopes, async lifecycle, интеграции с FastAPI/Litestar/FastStream и нормальная модель providers.

Когда использовать общий контейнер

Общий IoC-контейнер стоит добавлять, если:

  • в сервисе есть HTTP и broker/consumer;
  • одна и та же бизнес-логика вызывается из нескольких входов;
  • в тестах уже появились разные helper'ы для override зависимостей;
  • есть явное разделение на API, core/use-cases, db/infra;
  • хочется держать creation/lifecycle зависимостей отдельно от framework-кода.

Его, скорее всего, не стоит добавлять, если:

  • сервис маленький и живет только в FastAPI;
  • нужно очень быстро написать сервис без претензии на аккуратную архитектуру;
  • зависимостей мало и они не дублируются;
  • команда не готова следить за scopes и lifecycle;
  • контейнер нужен только "на будущее", без текущей боли.

Итог

Общий IoC-контейнер не делает приложение автоматически чище. Он просто переносит сборку зависимостей из фреймворков в отдельное место. Если границы слоев уже есть, это обычно помогает: API остается тонким, use-case'ы не знают про FastAPI/FastStream, а тесты используют один способ подмены зависимостей.

Если границ нет, контейнер может только добавить новый уровень путаницы. Поэтому dishka/anydi лучше воспринимать прагматично: это инструмент для проектов, где уже появилась реальная боль от нескольких DI-механизмов.