Внедрение зависимостей и composition root: как собирается граф объектов
Весь трек до этой главы отвечал на вопрос «какую форму придать коду»: порождающие
паттерны прятали создание объектов, структурные собирали из них
конструкции, поведенческие распределяли обязанности. Все они
молча предполагали, что кто-то снаружи уже связал объекты друг с другом: у Notifier откуда-то
взялся словарь каналов, у OrderService — репозиторий, у репозитория — пул соединений.
Этот «кто-то снаружи» и есть предмет главы. В GoF его нет: в 1994 году считалось нормальным, что объект сам создаёт свои части. Практика двадцати лет показала, что это и есть главный источник нетестируемого кода — и родила отдельный паттерн, который Гамма в интервью 2009 года назвал одним из кандидатов на включение в каталог: Dependency Injection.
Формулировка проблемы предельно простая. У объекта есть зависимости — другие объекты, без которых он не работает. Есть ровно три способа их получить: создать самому, найти в глобальном месте, получить снаружи. Первые два кажутся удобнее, третий — дороже. Дальше разберём, почему третий всё же выигрывает, где именно проходит граница и во что обходится ошибка со временем жизни.
Три способа получить зависимость
b = PostgresRepo(dsn)"] Q --> C2["2. Найти в глобальном месте
b = Locator.get('repo')"] Q --> C3["3. Получить снаружи
def __init__(self, b: Repo)"] C1 --> R1["Связность с конкретным классом.
Подменить нельзя, кроме как патчем модуля.
Время жизни B решает A — обычно неверно."] C2 --> R2["Зависимость невидима в сигнатуре.
Тест падает от порядка запуска.
Компилятор молчит, ошибка в рантайме."] C3 --> R3["Зависимость видна в типе.
Подмена бесплатна.
Но кто-то обязан собрать граф — и это работа."] R1 --> V["Годится для внутренних значений:
структуры данных, VO, DTO"] R2 --> W["Годится только там, где список
реализаций неизвестен на компиляции
(плагины)"] R3 --> X["По умолчанию — для всего,
что ходит в мир: БД, сеть, часы,
файлы, случайность"]
Важная оговорка сразу, иначе получится карго-культ. Не всякая зависимость требует инъекции.
Если 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 и связность/зацепление.
Формы инъекции и когда какая
инъекции)) Через конструктор умолчание для обязательных зависимостей объект после конструктора всегда валиден много аргументов - сигнал о лишних обязанностях Через метод или параметр зависимость нужна одному методу из пятнадцати время жизни равно вызову не удлиняет состояние объекта Через сеттер только для необязательных зависимостей есть окно, где объект недособран крайняя мера при разрыве циклов в легаси Ambient Context статический доступ к окружению уместен для трассировки, локали, времени для бизнес-данных - это скрытый синглтон
Инъекция через конструктор — умолчание. Ключевое свойство: объект после конструктора либо
полностью работоспособен, либо не создан. Никаких 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-объект живёт внутри запроса:
на U после dispose скоупа
Обратите внимание: скоуп владеет освобождением. Это ровно паттерн управления ресурсом, который подробно разбирается в следующей главе — паттерны ресурсов.
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. Это не техническая неприятность, а
сообщение о проектировании: два объекта считают друг друга своими частями.
от которой зависят оба Цикл --> Событие: B не зовёт A, а публикует событие Цикл --> Параметр: передать зависимость в метод,
а не в конструктор Цикл --> Ленивый: Lazy/Provider — отложить получение Цикл --> Сеттер: инъекция через сеттер Извлечь --> [*]: правильно в 80% случаев Событие --> [*]: правильно, если связь асинхронна по смыслу Параметр --> [*]: правильно, если зависимость нужна одному методу Ленивый --> [*]: обход, цикл остался Сеттер --> [*]: обход, объект временно невалиден
Первые три ветки убирают цикл, последние две его прячут. 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 и владение временем жизни