Dependency injection в Go без фреймворка: чек-лист перед продом
Go не навязывает контейнер зависимостей, и многие команды собирают граф руками — через конструкторы и интерфейсы. Подход работает, пока проект маленький. Разбираем, что проверить в ручном DI до выхода в продакшен, чтобы не получить неуправляемый main и утечки ресурсов.

Почему ручной DI в Go — норма, но не бесплатно
Стандартная библиотека Go не предлагает контейнера зависимостей, а community-фреймворки вроде wire, fx или dig часто кажутся избыточными для сервиса в двадцать пакетов. Поэтому типичный проект выглядит так: в main.go создаётся десяток структур, одна передаётся в конструктор другой, и всё запускается. Это чистый, явный DI — ровно то, что рекомендует сообщество.
Проблема не в паттерне, а в том, что при росте сервиса ручная сборка начинает молча ломаться: зависимости дублируются, ресурсы не закрываются при shutdown, тесты тянут за собой реальную базу. Чек-лист ниже — набор конкретных точек, которые стоит пройти до продакшена.
Чек-лист: 9 точек проверки
1. Все зависимости принимаются через конструктор, а не создаются внутри
Если структура сама вызывает sql.Open или http.Client{} в своём методе — это скрытая зависимость. Она не видна вызывающему коду и не заменяема в тесте. Конструктор NewService(db *sql.DB, client *http.Client) делает граф явным.
Проверка: откройте любой файл с New...-функцией. Если внутри есть вызов, который ходит наружу (сеть, диск, переменные окружения) — зависимость скрыта.
2. Интерфейсы объявлены на стороне потребителя
В Go принято определять интерфейс там, где он используется, а не там, где реализуется. Это позволяет сузить контракт до нужных методов и не тащить в тесты десятки методов, которые потребителю не нужны.
// package order — потребитель
type ProductStore interface {
GetByID(ctx context.Context, id int64) (Product, error)
}
type Service struct {
products ProductStore
}
func NewService(ps ProductStore) *Service {
return &Service{products: ps}
}
Проверка: если интерфейс лежит рядом с реализацией и содержит все её публичные методы — скорее всего, он объявлен не на той стороне.
3. main.go — единственное место, где собирается граф
Вся «проводка» живёт в одной функции (или в отдельном пакете app, который вызывается из main). Никакой пакет бизнес-логики не знает, откуда берётся его зависимость.
Проверка: grep -rn "sql.Open\|redis.New\|kafka.New" --include="*.go" . — все совпадения должны быть в main.go или cmd/.
4. У каждого ресурса есть явный shutdown
Открытые пулы соединений, gRPC-серверы, воркеры — всё это должно корректно останавливаться. Частая ошибка: ресурс создан в main, но defer resource.Close() стоит после select{} или signal.Notify, до которого defer никогда не дойдёт.
// Go 1.21+
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
srv := httpserver.New(handler)
// Запуск в горутине, ожидание сигнала — в main
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
Проверка: пройдите по каждому ресурсу в main. Есть ли для него путь закрытия, который реально выполнится при SIGTERM?
5. Нет глобальных переменных-зависимостей
Пакетный var db *sql.DB или var logger *zap.Logger на уровне пакета — антипаттерн. Он делает зависимость невидимой, а тесты — зависимыми от порядка запуска.
Проверка: grep -rn "^var " --include="*.go" . и смотрите, нет ли среди них чего-то, что содержит соединение, клиент или логгер.
6. Конфигурация отделена от конструирования
Чтение переменных окружения и разбор YAML — это одна ответственность. Создание структур — другая. Если конструктор принимает cfg Config, а не os.Getenv("DB_HOST"), вы можете тестировать сборку графа с любой конфигурацией.
7. Циклических зависимостей нет — и компилятор это подтверждает
Go не допускает циклический импорт на уровне пакетов, но позволяет создать цикл на уровне структур внутри одного пакета. Типичный симптом: ServiceA принимает ServiceB, а ServiceB принимает ServiceA. Решение — вынести общий контракт в интерфейс или разделить пакет.
8. Тесты не поднимают весь граф
Юнит-тест пакета order не должен знать о Postgres. Если интерфейсы на стороне потребителя узкие (пункт 2), мок создаётся за минуту — вручную или генератором вроде moq.
Интеграционные тесты, которым нужна реальная база, — отдельная сборочная цель с тегом //go:build integration.
9. Порядок инициализации читается сверху вниз
Граф зависимостей в main.go должен читаться как список: сначала конфиг, потом инфраструктура (БД, кеш, брокер), потом репозитории, потом сервисы, потом хендлеры, потом сервер. Если порядок нарушен или перемешан — при следующем изменении кто-то передаст ещё не инициализированный указатель.
Сравнение: ручной DI vs. кодогенерация vs. рефлексия
| Критерий | Ручной DI | wire (кодогенерация) | fx/dig (рефлексия) |
|---|---|---|---|
| Время компиляции | Без изменений | +кодогенерация перед сборкой | Без изменений |
| Ошибки обнаруживаются | При компиляции | При генерации | При старте приложения |
| Читаемость для нового разработчика | Высокая — весь граф в main | Средняя — нужно знать Provider Set | Низкая — граф неявный |
| Управление lifecycle | Вручную, через defer/shutdown | Вручную | Встроенное (fx.Lifecycle) |
| Удобство при 50+ зависимостях | main раздувается | Комфортно | Комфортно |
| Магия | Нулевая | Минимальная | Ощутимая |
Ручной DI оправдан, пока граф помещается в голове. Если main.go перевалил за 200 строк одной проводки — это сигнал посмотреть на wire: он генерирует ровно тот же код, который вы написали бы руками, но без ошибок в порядке и без забытых аргументов.
Лучший контейнер зависимостей в Go — это функция main. Она явная, типизированная и не требует документации. Проблемы начинаются, когда вы перестаёте к ней относиться как к архитектурному артефакту и начинаете сваливать туда всё подряд.
Где подход ломается
Ручной DI не масштабируется линейно. Вот конкретные точки, после которых трение растёт быстрее пользы:
- Много вариантов одной зависимости. Два разных HTTP-клиента с разными таймаутами для разных внешних API. В ручном DI вы даёте им разные имена переменных, и это работает. Но при десяти клиентах код превращается в ковёр переменных, и переименование ломает половину конструкторов.
- Условная инициализация. Feature-флаг включает интеграцию с платёжной системой. Вам нужен
ifвmain, который создаёт реальный клиент или заглушку. Три таких флага — восемь комбинаций, иmainстановится нечитаемым. - Несколько точек входа. Один и тот же граф нужен в HTTP-сервере, CLI-утилите и воркере. Без общего пакета сборки вы копируете проводку в три файла и забываете обновить один из них.
Если вы узнали свой проект хотя бы в одном пункте — это не повод немедленно тащить фреймворк, но повод вынести сборку графа в отдельный пакет internal/app и оценить, не сэкономит ли wire часы при каждом следующем изменении.
Что делать дальше
Пройдите по девяти пунктам на конкретном сервисе. Начните с main.go: нарисуйте граф на бумаге, отметьте, у каких узлов нет явного shutdown, у каких — скрытые зависимости. Это занимает полчаса и окупается при первом же инциденте с graceful shutdown.
Следующий шаг — интеграционные тесты. Если сейчас они поднимают весь граф через main, попробуйте собрать урезанный граф только для тестируемого слоя. Это возможно, только если пункты 1–3 выполнены: зависимости явные, интерфейсы узкие, сборка в одном месте.
Управление lifecycle — отдельная тема. Если ваш сервис держит больше трёх долгоживущих ресурсов, стоит посмотреть, как fx.Lifecycle решает эту задачу, даже если сам fx вы не возьмёте: идея хуков OnStart/OnStop легко воспроизводится руками в десяток строк.
Dependency injection (DI) — паттерн, при котором объект получает свои зависимости извне (через аргументы конструктора, параметры функции), а не создаёт их сам. В Go реализуется без магии: конструктор принимает интерфейсы или конкретные типы, а вызывающий код решает, что передать.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Go Wiki: Code Review Comments — Interfaces, Google Wire — Compile-time Dependency Injection for Go (GitHub)
Частые вопросы
Зачем вообще думать о DI в Go, если нет ни аннотаций, ни контейнеров?
Когда пора переходить с ручного DI на wire или fx?
Как тестировать сервис без контейнера, если у него десять зависимостей?
А если у меня другая версия Go — чек-лист актуален?
Глобальный логгер — это тоже скрытая зависимость?
Не получилось: при shutdown база не закрывается, данные теряются. Что проверить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.