ADR-0030: Runtime DI через uber-go/fx как единый композиционный корень

Status: accepted Date: 2026-04-20 Deciders: команда проекта

Контекст

Исходный ADR-0021 постулировал «только явную constructor-based сборку зависимостей в cmd/<service>/main.go» и запрещал runtime DI-контейнеры. Практика показала две проблемы:

  1. Микромонолитная топология. Ядро разворачивается как набор bounded context’ов внутри одного или нескольких Go-бинарей (api-server, позже — воркеры ingestion, enrichment, matching). Каждый BC приносит свой набор репозиториев/сервисов/хендлеров. Ручная сборка в одном main.go приводит к файлам в сотни строк, где порядок инициализации, lifecycle-порядок остановки, обработка readiness и регистрация хендлеров перемешаны с бизнес-wiring.
  2. Тестируемость композиции. Стенды и интеграционные тесты должны подменять репозитории/клиентов на mockery-моки. Без единого способа подмены это делается через дублированные wireXxx-функции в тестах, что дрейфует от production-wiring.

Нужен легковесный способ:

  • разнести сборку по модулям BC + platform-подсистемы;
  • иметь общий lifecycle (graceful start/stop) с автоматическим порядком остановки по обратной топологии;
  • единообразно подменять зависимости в тестах через mockery + override, не переписывая wiring.

Решение

Принят go.uber.org/fx в качестве обязательного DI runtime для всех композиционных корней Go-сервисов проекта (cmd/<process>/main.go, кроме CLI-утилит без зависимостей вроде cmd/errorlint).

Правила:

  1. Композиционный корень = fx.App. main.go содержит только построение fx.New(...) из модулей и вызов app.Run(). Никакой другой логики (signal-обработка, построение сервисов, readiness-чеки, FS-поиск и т.п.) в main.go не допускается.
  2. Модули. Каждый слой предоставляет fx.Option через exported-функцию:
    • internal/platform/di — платформенные модули: Core() (config/logger/clock), Observability(), Postgres(), Kafka(), Redis(), OpenAPI(), Health(), HTTP().
    • internal/core/<bc>/di.go<bc>.Module() для каждого bounded context’а. Модуль регистрирует репо, сервисы и хендлер-регистраторы.
  3. Lifecycle через fx.Lifecycle. Все ресурсы с внешним соединением (pgxpool, redis, kafka, http.Server) регистрируют fx.Hook{OnStart, OnStop}. fx сам вызывает остановку в обратном порядке.
  4. Readiness. Провайдер health.Aggregator собирает зарегистрированные health.Checker через fx.ParamTags/группы (fx.ResultTags с тэгом group:"health.checker"). HTTP-сервер инжектит агрегатор и выставляет /readyz.
  5. Тесты. Unit-тесты на доменную и app-логику НЕ используют fx — продолжают работать через обычные конструкторы. Интеграционные/composition-тесты используют fxtest.New(t, module, fx.Replace(mockRepo)) либо хелперы в internal/platform/di/testdi. Моки генерирует mockery.
  6. Reflection-based wiring разрешён в пределах fx. Скрытые singleton-зависимости и package-level init(), делающий IO-работу, по-прежнему запрещены.
  7. CLI-утилиты без внешних ресурсов (cmd/errorlint и т.п.) могут собираться без fx.
  8. Запрет “самодельных” DI-контейнеров. Вся композиция идёт через fx; альтернативные реестры сервисов/сервис-локаторы, map-based wiring и прочие варианты не допускаются.

Последствия

Плюсы

  • main.go становится декларативным, читаемым, стабильным между сервисами.
  • Единый lifecycle: graceful shutdown, включая pgxpool, kafka producer, redis — из коробки, без ручных defer-цепочек.
  • Модули BC изолированы и переиспользуемы в разных binary (api-server, будущие воркеры).
  • Тесты подменяют зависимости тем же механизмом, что и продовая сборка, без дублирующего wiring.
  • Единый способ добавить readiness-чек для нового ресурса — fx.Provide(fx.Annotated{Target: ..., Group: "health.checker"}).

Минусы

  • Reflection-based wiring. Ошибки типов всплывают в рантайме при fx.New, а не при компиляции. Митигация: fx.Validate в go test.
  • Ещё одна зависимость в go.mod. Митигация: uber-go/fx стабилен, широко используется, минимальный собственный набор зависимостей.
  • Кривая обучения для новых инженеров. Митигация: документация в backend/AGENTS.md + пример в internal/platform/di.

Нейтральные последствия

  • app.Run() заменяет ручную signal.NotifyContext-логику. Поведение при SIGINT/SIGTERM идентично — fx останавливает lifecycle-hooks в обратном порядке с StopTimeout.
  • Тесты, не зависящие от composition root (unit на доменную логику), не меняются.

Рассмотренные альтернативы

Явный constructor-based wiring в main.go (как было)

Отклонено: не масштабируется на 10+ BC, дублирует lifecycle-код в каждом cmd, тестовая подмена зависимостей требует параллельного wire-кода.

google/wire (compile-time codegen)

Отклонено: compile-time проверка хороша, но не решает lifecycle-проблему и требует отдельного codegen-шага в CI. Для микромонолита с динамикой окружений (опциональные Kafka/Redis) runtime-выбор модулей удобнее.

samber/do (runtime с generics)

Отклонено как default: современный и type-safe, но без встроенного lifecycle. Для текущей нагрузки lifecycle-хуки fx — ключевая ценность, переписывать поверх do — потеря преимущества.

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

Отклонено: получаем либо «fx lite» со своими багами, либо service-locator антипаттерн. Бессмысленно при наличии зрелого uber/fx.

Ссылки

  • Суперсед: частичный пересмотр ADR-0021 в части DI (пункт про runtime-контейнеры).
  • ../principles.md § P25 (переписан).
  • backend/AGENTS.md правило 16.
  • go.uber.org/fxhttps://uber-go.github.io/fx/