Паттерны проектирования Внедрение зависимостей и composition root: как собирается граф объектов
0%

Внедрение зависимостей и composition root: как собирается граф объектов

Внедрение зависимостей и 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)

Три вещи, которые здесь сделаны намеренно:

  1. Фабрика вместо готового объекта. make_service — способ выразить «время жизни: один запрос» без контейнера. Это ровно Abstract Factory из главы про порождающие, свёрнутый до функции.
  2. Часы инжектируются. Правило: любой источник недетерминизма — время, случайность, генерация идентификаторов, чтение переменных окружения — это зависимость. Иначе тесты становятся вероятностными (см. принципы тестирования).
  3. Ни одного импорта инфраструктуры вне 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

Честный список — в духе главы о том, когда паттерны мешают.

  1. Расстояние между созданием и использованием. Чтобы понять, какой класс придёт в интерфейс, надо открыть composition root. «Go to definition» ведёт в интерфейс. Это реальная потеря навигации, и она тем больнее, чем больше интерфейсов с одной реализацией.
  2. Ошибки конфигурации в рантайме — для рефлексивных контейнеров. Лечится валидацией графа на старте и тестом «приложение поднимается».
  3. Стоимость старта. Рефлексия, сканирование пакетов, построение графа: от миллисекунд до секунд.
  4. Соблазн лишних интерфейсов. DI не требует интерфейса: можно инжектировать конкретный класс. Интерфейс нужен, когда есть вторая реализация или иная причина шва, а не потому что «так принято». Это прямое продолжение разбора «интерфейс с одной реализацией» из главы 07.
  5. Конфигурационная связность. Граф в контейнере — тоже код, только без типов и без компилятора, если он описан аннотациями и 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-запросу.
  • FastAPIDepends в сигнатуре обработчика: 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 и владение временем жизни

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов