Внедрение зависимостей в Python: нужен ли DI-контейнер
Разбираем, когда ручной DI через параметры функций справляется сам, а когда стоит подключить контейнер вроде dependency-injector, и чем платишь за каждый вариант.

Каждый второй проект на Python рано или поздно упирается в один и тот же спор на ревью: один разработчик тянет в зависимости dependency-injector с контейнерами и провайдерами, второй говорит, что зависимости в Python прекрасно прокидываются через аргументы функции и класс — и весь этот слой лишний. Оба правы для своего масштаба. Разберём, где ручной DI держит нагрузку без библиотеки, где он превращается в клубок ручной проводки, и какую цену вы платите за контейнер.
Что вообще считаем внедрением зависимостей
Внедрение зависимостей (DI*) — это когда объект не создаёт свои зависимости сам, а получает их извне. Класс OrderService не вызывает PostgresRepo() внутри себя, а принимает готовый репозиторий в конструкторе. Вся идея умещается в одну строку и не требует ни одной библиотеки.
* DI (dependency injection) не то же самое, что DI-контейнер. Внедрение — это приём передачи зависимостей. Контейнер — инструмент, который централизованно собирает граф этих зависимостей и отдаёт готовые объекты. Первое нужно почти всегда, второе — не обязательно.
from dataclasses import dataclass
# Python 3.11+, зависимость приходит извне через конструктор
@dataclass
class OrderService:
repo: "OrderRepository"
notifier: "Notifier"
def place(self, order_id: int) -> None:
order = self.repo.get(order_id)
# ... бизнес-логика ...
self.notifier.send(order.customer_email, "Заказ принят")
Тест такого класса не требует базы и SMTP — вы подсовываете заглушки прямо в конструктор. Это и есть весь профит DI. Вопрос статьи в другом: кто собирает этот граф объектов и передаёт его в точку входа.
Подход 1. Ручная сборка в точке входа
Самый честный способ: создать все объекты в одном месте — в main(), в фабрике приложения, в стартовом модуле — и передать их вниз.
def build_order_service() -> OrderService:
# composition root: единственное место, где мы знаем конкретные классы
engine = create_engine(settings.database_url)
repo = PostgresOrderRepository(engine)
notifier = SmtpNotifier(settings.smtp_host)
return OrderService(repo=repo, notifier=notifier)
Этот единственный узел, где известны конкретные реализации, называют composition root — корнем композиции. Пока граф зависимостей плоский и объектов десяток, ручная сборка выигрывает у всего: её видно глазами, она не требует магии, стектрейс при падении ведёт прямо в строку, где вы что-то собрали не так.
Чем платите: когда граф разрастается до полусотни сервисов с общими зависимостями (один пул соединений на десять репозиториев, один HTTP-клиент на пять интеграций), функция сборки превращается в лапшу из локальных переменных, которые вы вручную протаскиваете. Управление жизненным циклом — «этот объект один на всё приложение, а этот новый на каждый запрос» — приходится писать самому.
Подход 2. Фабрики и замыкания
Промежуточный шаг: вместо готовых объектов передаёте фабрики. Помогает, когда объект нужно создавать лениво или несколько раз.
from functools import lru_cache
@lru_cache # синглтон на процесс без единой строки контейнера
def get_engine():
return create_engine(settings.database_url)
def get_order_repo() -> "OrderRepository":
return PostgresOrderRepository(get_engine())
lru_cache из стандартной библиотеки даёт вам синглтон бесплатно. FastAPI именно на этом строит свой Depends: фреймворк сам разрешает граф из функций-провайдеров и кеширует их в рамках запроса. Если вы уже на FastAPI, отдельный DI-контейнер чаще всего избыточен — механизм внедрения встроен.
Чем платите: ручное кеширование через lru_cache держится на глобальном состоянии, а его тяжело переопределять в тестах и опасно в многопоточной или async-среде. Область видимости «на запрос» руками уже не сделаешь без риска утечь состоянием между запросами.
Подход 3. DI-контейнер
Библиотека dependency-injector (актуальная линейка 4.x) выносит описание графа в декларативный контейнер: вы один раз объявляете, что из чего собирается и с каким временем жизни, а дальше просите у контейнера готовый сервис.
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
config = providers.Configuration()
# Singleton: один экземпляр на всё приложение
engine = providers.Singleton(create_engine, url=config.database_url)
# Factory: новый объект на каждый вызов
order_repo = providers.Factory(PostgresOrderRepository, engine=engine)
notifier = providers.Singleton(SmtpNotifier, host=config.smtp_host)
order_service = providers.Factory(
OrderService, repo=order_repo, notifier=notifier,
)
Провайдеры Singleton и Factory закрывают ровно ту боль, что в ручной сборке приходилось писать руками: время жизни объекта объявлено, а не разбросано по коду. В тестах любой провайдер переопределяется одной строкой:
with container.order_repo.override(providers.Object(fake_repo)):
service = container.order_service() # получит fake_repo
Чем платите: вход в библиотеку, декларативный слой, который читается не как обычный Python, и магия @inject/Provide, которую новому человеку в команде надо объяснять. Стектрейс при ошибке сборки идёт через внутренности контейнера, а не через ваш код.
Контейнер не решает проблему запутанного графа зависимостей. Он делает запутанность управляемой и наглядной. Если граф плоский — вы просто добавили слой абстракции поверх десяти строк build_...().
Сравнение подходов
| Критерий | Ручная сборка | Фабрики + lru_cache | DI-контейнер |
|---|---|---|---|
| Зависимости | нет | только stdlib | + библиотека 4.x |
| Порог входа в команде | нулевой | низкий | средний |
| Управление временем жизни | вручную | частично (кеш) | декларативно |
| Переопределение в тестах | подмена аргументов | неудобно (глобали) | override одной строкой |
| Читаемость стектрейса | максимум | высокая | ниже (магия) |
| Комфортный размер графа | до ~20 узлов | до ~30 узлов | от ~30 и выше |
Когда что выбирать
- Скрипт, утилита, небольшой сервис на 10-20 объектов — ручная сборка в composition root. Любая библиотека здесь чистый оверхед.
- Проект на FastAPI — встроенный
Dependsс функциями-провайдерами. Тянуть внешний контейнер имеет смысл, только если у вас логика собирается и вне HTTP-слоя (воркеры, CLI, крон). - Крупная кодовая база с воркерами, CLI и API поверх общего домена, где один и тот же граф сервисов нужен в нескольких точках входа, — контейнер окупается: он остаётся единым источником сборки для всех входов.
- Библиотека, которую импортируют другие — никаких контейнеров наружу. Принимайте зависимости через параметры, пусть потребитель сам решает, как их собирать.
Что ломается на практике
Типичные грабли независимо от подхода:
- Циклические зависимости.
AнуженB, аBнуженA. Ручная сборка обнаружит это сразу рекурсией, контейнер — при разрешении графа. Лечится не инструментом, а разрывом цикла: выделить общий интерфейс или третий объект. - Синглтон, который на деле должен жить на запрос. Классика: DB-сессию сделали
Singleton, и в async-приложении соединения текут между корутинами. Сессия — всегда область запроса, не приложения. - Глобальный контейнер как service locator. Соблазн импортировать контейнер в любом модуле и дёргать
container.something()прямо в бизнес-логике. Это возвращает вас к скрытым зависимостям, ради избавления от которых DI и заводили. Собирайте граф в корне, вниз передавайте готовые объекты.
Что делать дальше
Начните без библиотеки: вынесите создание объектов в одну функцию-composition root и передавайте зависимости через конструкторы. Это даёт тестируемость сразу и ничего не стоит. Считайте узлы графа: пока их меньше двадцати и точка входа одна — оставайтесь на ручной сборке. Когда появятся вторая и третья точки входа с общим доменом или управление временем жизни начнёт растекаться по коду — оцените dependency-injector на одном модуле, прежде чем переписывать всё. Мерить стоит не строки кода, а время, которое новый разработчик тратит, чтобы понять, откуда в сервисе взялась зависимость.
Разрушитель авто-разводов: Фото заказ-наряда СТО: завышения, дубли работ, лишние позиции.
Спросить за 15 ₽Источники: dependency-injector — официальная документация, FastAPI — Dependencies
Частые вопросы
А если я уже на FastAPI — нужен ли отдельный DI-контейнер?
Работает ли dependency-injector с async и корутинами?
Что использовать вместо контейнера в маленьком проекте?
Чем DI-контейнер отличается от service locator?
Замедляет ли контейнер приложение?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.