ADR-0030: Runtime DI через uber-go/fx как единый композиционный корень
Status: accepted Date: 2026-04-20 Deciders: команда проекта
Контекст
Исходный ADR-0021 постулировал «только явную constructor-based сборку зависимостей в cmd/<service>/main.go» и запрещал runtime DI-контейнеры. Практика показала две проблемы:
- Микромонолитная топология. Ядро разворачивается как набор bounded context’ов внутри одного или нескольких Go-бинарей (
api-server, позже — воркеры ingestion, enrichment, matching). Каждый BC приносит свой набор репозиториев/сервисов/хендлеров. Ручная сборка в одномmain.goприводит к файлам в сотни строк, где порядок инициализации, lifecycle-порядок остановки, обработка readiness и регистрация хендлеров перемешаны с бизнес-wiring. - Тестируемость композиции. Стенды и интеграционные тесты должны подменять репозитории/клиентов на 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).
Правила:
- Композиционный корень =
fx.App.main.goсодержит только построениеfx.New(...)из модулей и вызовapp.Run(). Никакой другой логики (signal-обработка, построение сервисов, readiness-чеки, FS-поиск и т.п.) вmain.goне допускается. - Модули. Каждый слой предоставляет
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’а. Модуль регистрирует репо, сервисы и хендлер-регистраторы.
- Lifecycle через
fx.Lifecycle. Все ресурсы с внешним соединением (pgxpool, redis, kafka, http.Server) регистрируютfx.Hook{OnStart, OnStop}.fxсам вызывает остановку в обратном порядке. - Readiness. Провайдер
health.Aggregatorсобирает зарегистрированныеhealth.Checkerчерезfx.ParamTags/группы (fx.ResultTagsс тэгомgroup:"health.checker"). HTTP-сервер инжектит агрегатор и выставляет/readyz. - Тесты. Unit-тесты на доменную и app-логику НЕ используют fx — продолжают работать через обычные конструкторы. Интеграционные/composition-тесты используют
fxtest.New(t, module, fx.Replace(mockRepo))либо хелперы вinternal/platform/di/testdi. Моки генерируетmockery. - Reflection-based wiring разрешён в пределах fx. Скрытые singleton-зависимости и
package-level init(), делающий IO-работу, по-прежнему запрещены. - CLI-утилиты без внешних ресурсов (
cmd/errorlintи т.п.) могут собираться без fx. - Запрет “самодельных” 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/fx— https://uber-go.github.io/fx/