Внедрение зависимостей и composition root: как собирается граф объектов
Весь трек до этой главы отвечал на вопрос «какую форму придать коду»: порождающие
паттерны прятали создание объектов, структурные собирали из них
конструкции, поведенческие распределяли обязанности. Все они
молча предполагали, что кто-то снаружи уже связал объекты друг с другом: у Notifier откуда-то
взялся словарь каналов, у OrderService — репозиторий, у репозитория — пул соединений.
Этот «кто-то снаружи» и есть предмет главы. В GoF его нет: в 1994 году считалось нормальным, что объект сам создаёт свои части. Практика двадцати лет показала, что это и есть главный источник нетестируемого кода — и родила отдельный паттерн, который Гамма в интервью 2009 года назвал одним из кандидатов на включение в каталог: Dependency Injection.
Формулировка проблемы предельно простая. У объекта есть зависимости — другие объекты, без которых он не работает. Есть ровно три способа их получить: создать самому, найти в глобальном месте, получить снаружи. Первые два кажутся удобнее, третий — дороже. Дальше разберём, почему третий всё же выигрывает, где именно проходит граница и во что обходится ошибка со временем жизни.
Три способа получить зависимость
Важная оговорка сразу, иначе получится карго-культ. Не всякая зависимость требует инъекции.
Если OrderService внутри метода создаёт Money(100, "RUB") или list() — это не зависимость, это
значение. Инъекции требует то, что вы захотите подменить: потому что оно ходит наружу (БД, HTTP,
файловая система), потому что оно недетерминированно (время, случайность, UUID), или потому что у
него есть несколько реальных вариантов (тарифы, шлюзы, каналы уведомлений).
Простой критерий: если в тесте вы это будете мокать — инжектируйте; если нет — создавайте на месте.
Строго: DI, IoC, DIP — три разных слова
Их постоянно путают, и путаница мешает разговору на ревью.
| Термин | Уровень | Что утверждает |
|---|---|---|
| DIP (Dependency Inversion Principle) | принцип | Модули верхнего уровня не зависят от нижних; оба зависят от абстракций. Про направление зависимостей |
| IoC (Inversion of Control) | обобщённая идея | Управление потоком отдано наружу: не вы вызываете фреймворк, а фреймворк вас (Hollywood principle). Шире DI: сюда же шаблонный метод, колбэки, событийный цикл |
| DI (Dependency Injection) | паттерн | Объект получает зависимости извне, а не добывает их сам. Одна из реализаций IoC |
| DI-контейнер | инструмент | Библиотека, которая по метаданным собирает граф объектов за вас. Не обязателен для DI |
Практический вывод: DI без контейнера — это норма, а не компромисс. Контейнер экономит ручную сборку в больших графах; в приложении на 30 классов он часто добавляет больше, чем экономит. Подробнее о принципах — SOLID и связность/зацепление.
Формы инъекции и когда какая
Инъекция через конструктор — умолчание. Ключевое свойство: объект после конструктора либо
полностью работоспособен, либо не создан. Никаких NullPointerException на третьем вызове.
Инъекция через сеттер оставляет окно, в котором объект существует, но неисправен. Оправдана в двух случаях: зависимость действительно необязательна (метрики, логгер с no-op по умолчанию) и при разрыве цикла в легаси, когда переписать конструкторы дорого.
Инъекция через метод (параметр) — самая недооценённая форма. Если зависимость нужна одному методу из пятнадцати, тащить её через конструктор в поле — значит удлинять время жизни без причины.
Ambient Context (статический доступ к «окружению»: CultureInfo.CurrentCulture,
контекст трассировки, contextvars в Python) — это скрытая зависимость, оправданная только для
сквозных технических вещей, которые иначе протекли бы в каждую сигнатуру. Класс, который читает
из ambient context бизнес-данные, тестировать так же больно, как синглтон.
Про количество аргументов. Конструктор с семью зависимостями — не проблема DI, а сигнал: класс делает слишком много (см. God Object). Марк Симанн называет лечение Facade Service: сгруппировать три-четыре зависимости, всегда используемые вместе, в один объект с осмысленным именем. Не «уменьшить число параметров», а найти пропущенную абстракцию.
Composition root: одно место, где всё связывается
Composition root — единственный участок приложения, который знает про конкретные классы и собирает из них граф. Всё остальное знает только интерфейсы.
Правило звучит жёстко и работает: composition root — единственное место, где упоминается контейнер
(или ручная сборка). Как только container.resolve(...) появляется в сервисе, DI выродился в
Service Locator, и все его преимущества испарились.
Типичные точки composition root по типам приложений:
| Тип приложения | Где composition root |
|---|---|
| Консольная утилита | main() |
| Веб-сервис | фабрика приложения (create_app, Startup.ConfigureServices, @SpringBootApplication) |
| Воркер очереди | точка входа воркера, до начала цикла потребления |
| Тест | фикстура/setUp — у теста свой composition root, и это нормально |
| Библиотека | отсутствует. Библиотека не собирает граф, она принимает зависимости. Библиотека с зашитым контейнером — источник конфликтов у пользователей |
Последняя строка важнее, чем кажется. Если вы пишете переиспользуемый пакет, он не должен решать, какой логгер, какой пул и какой контейнер использует приложение.
Ручная сборка: Python без контейнера
Начнём с варианта, который в Python покрывает 90% реальных нужд.
# domain/ports.py — контракты, которые нужны домену. Никаких импортов из инфраструктуры.
from typing import Protocol
from datetime import datetime
from decimal import Decimal
class OrderRepository(Protocol):
def get(self, order_id: str) -> "Order": ...
def save(self, order: "Order") -> None: ...
class PaymentGateway(Protocol):
def charge(self, order_id: str, amount: Decimal) -> str: ...
class Clock(Protocol):
def now(self) -> datetime: ...
# application/order_service.py — вся бизнес-логика, ноль знаний о конкретике.
class OrderService:
def __init__(
self,
orders: OrderRepository,
payments: PaymentGateway,
clock: Clock,
) -> None:
self._orders = orders
self._payments = payments
self._clock = clock
def pay(self, order_id: str) -> str:
order = self._orders.get(order_id)
# Время — такая же зависимость, как база. Иначе тест «истёк ли заказ»
# придётся писать через freezegun или ждать сутки.
if order.expires_at < self._clock.now():
raise OrderExpired(order_id)
receipt = self._payments.charge(order_id, order.total)
order.mark_paid(receipt, at=self._clock.now())
self._orders.save(order)
return receipt
# main.py — composition root. Единственный файл, который знает про Postgres и Stripe.
import os
from psycopg_pool import ConnectionPool
from application.order_service import OrderService
from infrastructure.postgres import PostgresOrderRepository
from infrastructure.stripe_gateway import StripeGateway
from infrastructure.clock import SystemClock
def build_app() -> "App":
# 1. Ресурсы с долгим временем жизни создаются один раз.
pool = ConnectionPool(os.environ["DATABASE_URL"], min_size=2, max_size=20)
gateway = StripeGateway(api_key=os.environ["STRIPE_KEY"], timeout=5.0)
clock = SystemClock()
# 2. Граф собирается снизу вверх, руками, в явном порядке.
def make_service(conn) -> OrderService:
# Репозиторий живёт ровно один запрос — вместе с транзакцией.
return OrderService(PostgresOrderRepository(conn), gateway, clock)
return App(pool=pool, make_service=make_service)
Три вещи, которые здесь сделаны намеренно:
- Фабрика вместо готового объекта.
make_service— способ выразить «время жизни: один запрос» без контейнера. Это ровно Abstract Factory из главы про порождающие, свёрнутый до функции. - Часы инжектируются. Правило: любой источник недетерминизма — время, случайность, генерация идентификаторов, чтение переменных окружения — это зависимость. Иначе тесты становятся вероятностными (см. принципы тестирования).
- Ни одного импорта инфраструктуры вне
main.py. Это можно и нужно проверять автоматически:import-linterв Python,ArchUnitв Java,depguardв Go.
Тест такого сервиса не требует ни моков, ни контейнера:
def test_pay_rejects_expired_order():
clock = FixedClock(datetime(2026, 7, 16, 12, 0))
orders = InMemoryOrderRepository([Order(id="o-1", expires_at=datetime(2026, 7, 16, 11, 0))])
payments = RecordingGateway()
service = OrderService(orders, payments, clock)
with pytest.raises(OrderExpired):
service.pay("o-1")
assert payments.calls == [] # проверяем поведение, а не вызовы мока
Go: связывание структурами, без магии
В Go DI-контейнеры не прижились, и это осознанный выбор сообщества: граф собирается в main,
компилятор проверяет его целиком.
// Интерфейс объявляет ПОТРЕБИТЕЛЬ — это меняет всё: контракт узкий,
// а реализация о нём даже не знает. «Accept interfaces, return structs».
type OrderRepository interface {
Get(ctx context.Context, id string) (Order, error)
Save(ctx context.Context, o Order) error
}
type OrderService struct {
orders OrderRepository
payments PaymentGateway
now func() time.Time // зависимость-функция вместо интерфейса на один метод
}
func NewOrderService(r OrderRepository, p PaymentGateway, now func() time.Time) *OrderService {
if now == nil {
now = time.Now // разумное умолчание, но подменяемое
}
return &OrderService{orders: r, payments: p, now: now}
}
func main() {
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("пул соединений: %v", err)
}
defer pool.Close()
svc := NewOrderService(
postgres.NewOrderRepository(pool),
stripe.NewGateway(os.Getenv("STRIPE_KEY")),
time.Now,
)
http.ListenAndServe(":8080", api.NewRouter(svc)) // граф собран, дальше он неизменен
}
Когда граф разрастается до сотни узлов, в Go берут кодогенератор, а не рефлексию:
wire читает сигнатуры конструкторов и пишет тот самый main,
который вы написали бы руками. Ошибка связывания становится ошибкой компиляции, а не паникой
на старте. Альтернатива с рефлексией — uber-go/fx и dig — даёт
цену в виде непрозрачных ошибок в рантайме.
Времена жизни и главная ошибка: захваченная зависимость
Как только появляется контейнер, появляется вопрос: сколько живёт объект. Три канонических времени жизни (терминология .NET, но идея универсальна):
| Время жизни | Сколько экземпляров | Типичные жильцы | Опасность |
|---|---|---|---|
| Singleton | один на процесс | пул соединений, HTTP-клиент, конфиг, реестр метрик | должен быть потокобезопасен |
| Scoped | один на запрос/транзакцию | Unit of Work, сессия ORM, контекст пользователя | нельзя удерживать дольше запроса |
| Transient | новый на каждое разрешение | лёгкие stateless-объекты, команды | скрытая аллокация в горячем пути |
Captive dependency (захваченная зависимость) — когда объект с долгим временем жизни держит ссылку на объект с коротким. Классика: синглтон-сервис получил в конструктор scoped-сессию БД. Формально всё собралось; фактически сессия первого запроса живёт вечно, видит устаревшие данные, а с приходом второго потока ломается по гонке.
// C#, Microsoft.Extensions.DependencyInjection
services.AddDbContext<AppDbContext>(); // Scoped по умолчанию
services.AddSingleton<CacheWarmer>(); // Singleton
// CacheWarmer(AppDbContext db) — БОМБА: scoped захвачен синглтоном.
// Правильно: синглтон получает фабрику скоупов и открывает свой на каждую работу.
public sealed class CacheWarmer
{
private readonly IServiceScopeFactory _scopes;
public CacheWarmer(IServiceScopeFactory scopes) => _scopes = scopes;
public async Task WarmAsync(CancellationToken ct)
{
using var scope = _scopes.CreateScope(); // свой скоуп на итерацию
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Products.Take(1000).LoadAsync(ct);
}
}
Хорошая новость: это ловится автоматически. В .NET — ValidateScopes и ValidateOnBuild
(в среде разработки включены по умолчанию), в Spring — @Scope(proxyMode = TARGET_CLASS) плюс
явные ошибки при внедрении request-бина в синглтон. Включите проверку в CI: она находит
целый класс ошибок бесплатно.
Как scoped-объект живёт внутри запроса:
Обратите внимание: скоуп владеет освобождением. Это ровно паттерн управления ресурсом, который подробно разбирается в следующей главе — паттерны ресурсов.
Service Locator: почему это антипаттерн, а не «второй вариант DI»
Service Locator — глобальный реестр, из которого объект сам достаёт зависимости:
class OrderService:
def __init__(self) -> None:
self._orders = Locator.get(OrderRepository) # зависимость невидима снаружи
self._payments = Locator.get(PaymentGateway)
Сравнение по существу, а не по вкусу:
| Критерий | DI (конструктор) | Service Locator |
|---|---|---|
| Видны ли зависимости | да, в сигнатуре | нет, только чтением тела |
| Когда падает при неполной конфигурации | при сборке графа, на старте | в рантайме, в момент первого вызова |
| Тест | передал фейк и всё | нужно настроить глобальный реестр и откатить его |
| Изоляция тестов | полная | тесты влияют друг на друга через общий реестр |
| Переиспользование класса в другом приложении | свободно | тянет за собой локатор |
Марк Симанн разобрал это в «Service Locator is an Anti-Pattern»: главный вред не в глобальности, а в том, что класс перестаёт честно объявлять свои требования. Компилятор и ревьюер теряют возможность заметить, что вы добавили пятую зависимость.
Единственная честная ниша локатора — там, где множество реализаций неизвестно на этапе компиляции: загрузка плагинов, разрешение обработчиков по типу сообщения. Об этом — глава точки расширения.
Контейнер на компиляции против контейнера в рантайме
Практические ориентиры выбора:
- До ~50 узлов графа ручная сборка выигрывает: она читается сверху вниз, отлаживается обычным отладчиком и не требует знания фреймворка.
- Экосистема решает. В Spring или ASP.NET Core бороться с контейнером бессмысленно — половина фреймворка построена вокруг него.
- Мобильные и AOT-сценарии (Android, iOS, .NET Native AOT, Go) выбирают кодогенерацию: рефлексия дорога на старте и плохо дружит с обрезкой неиспользуемого кода.
- Стартап-время — измеримая величина. Spring Boot с сотнями бинов стартует секунды; для serverless (см. serverless и edge) это прямые деньги, поэтому там появились компиляционные варианты вроде Spring Native.
Циклы в графе: что это значит и как чинить
Контейнер сообщил Circular dependency: A → B → A. Это не техническая неприятность, а
сообщение о проектировании: два объекта считают друг друга своими частями.
Первые три ветки убирают цикл, последние две его прячут. Spring Boot начиная с 2.6 по умолчанию
запрещает циклы (spring.main.allow-circular-references=false) именно потому, что «спрятать»
оказалось слишком доступной кнопкой.
Цена: что вы платите за DI
Честный список — в духе главы о том, когда паттерны мешают.
- Расстояние между созданием и использованием. Чтобы понять, какой класс придёт в интерфейс, надо открыть composition root. «Go to definition» ведёт в интерфейс. Это реальная потеря навигации, и она тем больнее, чем больше интерфейсов с одной реализацией.
- Ошибки конфигурации в рантайме — для рефлексивных контейнеров. Лечится валидацией графа на старте и тестом «приложение поднимается».
- Стоимость старта. Рефлексия, сканирование пакетов, построение графа: от миллисекунд до секунд.
- Соблазн лишних интерфейсов. DI не требует интерфейса: можно инжектировать конкретный класс. Интерфейс нужен, когда есть вторая реализация или иная причина шва, а не потому что «так принято». Это прямое продолжение разбора «интерфейс с одной реализацией» из главы 07.
- Конфигурационная связность. Граф в контейнере — тоже код, только без типов и без компилятора, если он описан аннотациями и XML.
Типичные ошибки
| Ошибка | Как выглядит | Что делать |
|---|---|---|
| Контейнер внутри сервиса | container.resolve(...) вне composition root |
Вернуть зависимости в конструктор |
| Захваченная зависимость | Singleton получил Scoped | Фабрика скоупов; включить ValidateScopes/аналог |
| Инъекция ради тестов | Интерфейс на класс без второй реализации | Подменять транспорт/время, а не домен |
| Конструктор на 8 аргументов | «DI виноват» | Facade Service; проверить, не God Object ли это |
Часы и uuid4() внутри домена |
Тесты «мигают» | Clock, IdGenerator как зависимости |
| Библиотека со своим контейнером | Пакет тянет Spring/фреймворк | Библиотека принимает зависимости, а не собирает граф |
Разрыв цикла через @Lazy |
Цикл «починен», связность прежняя | Извлечь третью абстракцию или событие |
| Регистрация «всё Transient» | Пул соединений создаётся на запрос | Осознанно назначать время жизни ресурсам |
Как это выглядит в проде
- Spring — родоначальник массового DI на JVM:
@Component,@Configuration, области видимостиsingleton/prototype/request. Подробности — в главе про Spring. - ASP.NET Core — контейнер встроен в платформу;
IServiceCollectionвProgram.csи есть composition root, аIServiceScopeпривязан к HTTP-запросу. - FastAPI —
Dependsв сигнатуре обработчика: DI, встроенный в тип функции, со скоупом запроса и автоматическим освобождением через генераторы. - Go — ручная сборка в
mainкак норма;wireдля больших графов. - Android — Dagger/Hilt: граф проверяется компилятором, генерируется код, рефлексии нет.
- Тестовые фикстуры — pytest
fixture, JUnit@TestConfiguration: у теста собственный composition root, где инфраструктура заменена на фейки и контейнеры (интеграционное тестирование).
Мини-итог
- Зависимость получают тремя способами; выбирать инъекцию стоит для всего, что ходит наружу или недетерминированно, и не стоит для значений.
- DI ≠ контейнер. Ручная сборка в
main— полноценный DI, и для среднего приложения она лучше. - Composition root — единственное место, знающее конкретные классы. Появление контейнера в бизнес-коде превращает DI в Service Locator и отменяет выгоду.
- Конструкторная инъекция — умолчание: объект либо валиден, либо не создан.
- Время жизни — часть проектирования. Захваченная зависимость (singleton держит scoped) — самая частая и самая дорогая ошибка; включайте валидацию графа.
- Цикл в графе — сигнал о проектировании, а не техническая помеха: извлекайте третью абстракцию.
- Плата за DI: расстояние между созданием и использованием, ошибки конфигурации, соблазн интерфейсов «на всякий случай». Всё это — та же экономика ставки на будущее изменение, что и у любого паттерна.
Источники
- Martin Fowler, «Inversion of Control Containers and the Dependency Injection pattern», 2004 — martinfowler.com. Первоисточник термина и разбора трёх форм инъекции.
- Mark Seemann, Steven van Deursen, «Dependency Injection Principles, Practices, and Patterns», 2019 — manning.com. Composition Root, Captive Dependency, Facade Service — оттуда.
- Mark Seemann, «Service Locator is an Anti-Pattern», 2010 — blog.ploeh.dk.
- Microsoft, «Dependency injection in .NET» —
learn.microsoft.com,
раздел о временах жизни и
ValidateScopes. - Spring Framework Reference, «The IoC Container» — docs.spring.io.
- Google wire — github.com/google/wire, генерация графа на этапе сборки.
- FastAPI, «Dependencies» — fastapi.tiangolo.com.
- Michael Feathers, «Working Effectively with Legacy Code», 2004 — понятие шва: DI и есть самый дешёвый способ его создать.
Что дальше
Мы разобрали, кто создаёт и связывает объекты. Следующий вопрос — кто их освобождает: соединения,
файлы, буферы и блокировки надо возвращать, и здесь у паттернов своя, очень практичная ветка —
от RAII и with до пула объектов с проверкой на пригодность.
Паттерны ресурсов: Object Pool, RAII и владение временем жизни