Я считаю, что при работе с Go в контексте нашей отрасли внедрение зависимостей (dependency injection, DI) часто имеет плохую репутацию из-за DI-фреймворков. Но сама по себе DI как техника довольно полезна. Просто её объясняют слишком большим количеством ОО-жаргона, что приводит к ПТСР у тех, кто перешёл на Go, чтобы сбежать из культа банды четырёх.

Внедрение зависимостей — это 25-долларовый термин для 5-центовой концепции.

— Джеймс Шор

По сути, DI означает передачу значений в конструктор вместо того, чтобы создавать их внутри него. Вот и всё. Глядите:

type server struct {
    db DB
}

// NewServer создаёт экземпляр server
func NewServer() *server {
    db := DB{}            // Здесь создаётся зависимость
    return &server{db: db}
}

В этом примере NewServer создаёт собственную DB. Вместо этого для внедрения зависимости можно создать DB в другом месте и передать её, как параметр конструктора:

func NewServer(db DB) *server {
    return &server{db: db}
}

Теперь конструктор не решает, как создаётся база данных, а просто получает её.

В Go DI часто реализуется при помощи интерфейсов. Мы сравниваем важное нам поведение в интерфейсе, а затем предоставляем различные конкретные реализации для разных контекстов. В продакшене мы передаём в DB реальную реализацию. В юнит-текстах передаётся имитация реализации, которая ведёт себя точно так же с точки зрения вызывающей стороны, но не выполняет вызовов реальной базы данных.

Вот, как это выглядит:

// важное нам поведение
type DB interface {
    Get(id string) (string, error)
    Save(id, value string) error
}

type server struct{ db DB }

// NewServer получает конкретную реализацию интерфейса DB в среде выполнения
// и передаёт её структуре server.
func NewServer(db DB) *server { return &server{db: db} }

Реальная реализация DB может выглядеть так:

type RealDB struct{ url string }

func NewDB(url string) *RealDB { return &RealDB{url: url} }

func (r *RealDB) Get(id string) (string, error) {
    // делаем вид, что обратились к Postgres
    return "real value", nil
}
func (r *RealDB) Save(id, value string) error { return nil }

А имитация реализации для юнит-тестов может выглядеть так:

type FakeDB struct{ data map[string]string }

func NewFake() *FakeDB { return &FakeDB{data: map[string]string{}} }

func (f *FakeDB) Get(id string) (string, error) { return f.data[id], nil }
func (f *FakeDB) Save(id, value string) error   { f.data[id] = value; return nil }

Имитацию можно использовать в юнит-тестах так:

func TestServerGet(t *testing.T) {
    fake := NewFake()
    _    = fake.Save("42", "fake")

    srv := NewServer(fake)
    val, _ := srv.db.Get("42")

    if val != "fake" {
        t.Fatalf("want fake, got %s", val)
    }
}

Компилятор гарантирует, что и RealDB, и FakeDB удовлетворяют DB, а во время тестов мы без особых церемоний можем заменить реализации.

Почему фреймворки превращают небольшие неудобства в огромную боль

Как только NewServer разрастается до пяти и больше зависимостей, соединение их вручную может показаться запутанным процессом. Привлекательной становится перспектива использования DI-фреймворка.

При работе с dig Uber каждый конструктор регистрируется, как провайдер. Provide получает функцию, использует рефлексию для изучения её параметров и возвращаемого типа, а затем добавляет её в качестве зависимости во внутренний граф зависимостей. Пока ничего не выполняется. Выполнение начинается только при вызове .Invoke() для контейнера.

Но именно в этой магии рефлексии и начинается боль. С ростом графа сложнее становится понять, какой конструктор выполняет передачу какому. Некоторые конструкторы получают один параметр, некоторые три. Нет какого-то одного места, в котором с первого взгляда можно разобраться в связях. Всё это выясняется внутри контейнера в среде выполнения.

Пусть разбирается контейнер!

— любой DI-фреймворк

func BuildContainer() *dig.Container {
    c := dig.New()
    // Каждый вызов Provide сообщает dig об одном узле в графе.
    c.Provide(NewConfig)     // создаёт *Config
    c.Provide(NewDB)         // требует *Config, создаёт *DB
    c.Provide(NewRepo)       // требует *DB, создаёт *Repo
    c.Provide(NewFlagClient) // создаёт *FlagClient
    c.Provide(NewService)    // требует *Repo, *FlagClient, создаёт *Service
    c.Provide(NewServer)     // требует *Service, создаёт *server
    return c
}

func main() {
    // Invoke запускает работу всего графа. dig выполняет топологическую сортировку,
    // вызывает каждый конструктор, а затем передаёт *server на обратный вызов.
    if err := BuildContainer().Invoke(
    	func(s *server) { s.Run() }); err != nil {
    	panic(err)
    }
}

А теперь попробуем убрать NewFlagClient в комментарий. Код всё равно будет компилироваться. Не возникнет никаких ошибок до времени выполнения, когда dig не удастся создать NewService из-за отсутствующей зависимости. И какое же сообщение об ошибке мы получим?

dig invoke failed: could not build arguments for function
        main.main.func1 (prog.go:87)
    : failed to build *main.Server
    : could not build arguments for function main.NewServer (prog.go:65)
    : failed to build *main.Service: missing dependencies for function
        main.NewService (prog.go:55)
    : missing type: *main.FlagClient

Оно уходит в глубину на пять кадров стека, далеко от того места, где возникла проблема. Теперь нам приходится копаться во внутренностях dig, чтобы мысленно воссоздать граф.

В wire Google используется другой подход: он переносит процесс создания графа на этап генерации кода. Мы собираем конструкторы в wire.NewSet, вызываем wire.Build и генератор записывает wire_gen.go, который соединяет всё в явном виде.

var serverSet = wire.NewSet(
    NewConfig,
    NewDB,
    NewRepo,
    NewFlagClient,   // закомментируйте эту строку, чтобы Wire начал жаловаться во время компиляции
    NewService,
    NewServer,
)

func InitializeServer() (*server, error) {
    wire.Build(serverSet)
    return nil, nil // заменено сгенерированным кодом
}

Если скрыть NewFlagClient в комментарий, то сбой Wire возникнет раньше, на этапе генерации:

wire: ../../service/wire.go:13:2: cannot find dependency for *flags.Client

Это лучше, чем паника dig в среде выполнения, но всё равно имеет свои недостатки:

  • Необходимо не забывать запускать go generate ./... при каждой смене сигнатур конструкторов.

  • Когда что-то ломается, для выявления проблемы приходится читать сотни строк автоматически сгенерированного клея.

  • Нужно обучить каждого члена команды DSL Wire —wire.NewSetwire.Build, тэгам сборки и sentinel-правилам. А если вы когда-нибудь решите перейти на что-то другое, например на dig, то придётся изучать совершенно другое множество концепций: Provide, Invoke, области видимости, именованные значения и так далее.

Хотя в DI-фреймворках используются термины наподобие «провайдер» и «контейнер», упрощающие освоение, они всё равно каждый раз изобретают поверхность API заново. При переходе между ними требуется заново осваивать новую ментальную модель.

То есть обещание того, что нам достаточно будет зарегистрировать провайдеров и забыть о связях, на самом деле оборачивается сменой чёткого контроля времени компиляции или на рефлексию, или на скрытую логику генератора; а это ещё один слой абстракции, который нам нужно будет отлаживать.

Скучная альтернатива: оставить связи явными

В Go можно просто писать собственные зависимости вручную. Пример:

func main() {
    cfg := NewConfig()

    db    := NewDB(cfg.DSN)
    repo  := NewRepo(db)
    flags := NewFlagClient(cfg.FlagURL)

    svc := NewService(repo, flags, cfg.APIKey)
    srv := NewServer(svc, cfg.ListenAddr)

    srv.Run()
}

Это длиннее? Да. Но:

  • Порядок вызова — это и есть граф зависимостей.

  • Ошибки обрабатываются ровно там, где они возникают.

  • При изменении конструктора компилятор указывает на каждый поломанный вызов:

    ./main.go:33:39: not enough arguments in call to NewService
        have (*Repo, *FlagClient)
        want (*Repo, *FlagClient, string)

Никакой рефлексии, никакого генерируемого кода, никакого глобального состояния. Go проверяет типы графа зависимости сразу и явно, как это и должно происходить. Кроме того, это не запутывает LSP, поэтому IDE не теряет своей полезности.

Если main() всё-таки слишком разрастётся, то разбейте свой код:

func buildInfra(cfg *Config) (*DB, *FlagClient, error) {
    // ...
}

func buildService(cfg *Config) (*Service, error) {
    db, flags, err := buildInfra(cfg)
    if err != nil { return nil, err }
    return NewService(NewRepo(db), flags, cfg.APIKey), nil
}

func main() {
    cfg := NewConfig()
    svc, err := buildService(cfg)
    if err != nil { log.Fatal(err) }
    NewServer(svc, cfg.ListenAddr).Run()
}

Каждая вспомогательная функция — это обычная функция, в которой может разобраться каждый, не читая руководство по фреймворку. К тому же мы вручную создали все свои зависимости в одном и на самом деле не так страшно, если ваша функция сборки получает двадцать параметров и собирает все зависимости. Достаточно поместить каждый параметр функции в отдельную сроку и использовать gofumpt, чтобы отформатировать код, сделав его читаемым.

Рефлексия работает везде, но не здесь?

В других языках контейнеры используются, потому что конструкторы часто нельзя перегружать, и от этого страдает время компиляции. Однако в Go уже есть:

  • Функции первого класса, поэтому конструкторы — это простые значения.

  • Интерфейсы, поэтому в тестах реализации можно легко менять.

  • Быстрая компиляция, поэтому циклы обратной связи остаются короткими.

DI-фреймворк часто устраняет проблемы, которые Go уже решил, при этом жертвуя для этого читаемостью.

Самое магическое в Go то, насколько мало магии он допускает.

— Какой-то разработчик на Go с Reddit

Возможно, вам всё равно понадобится фреймворк

Легко громко заявить, что никогда не следует пользоваться DI-фреймворком, но здесь важен контекст.

Я смотрел доклад Uber о том, как компания использует Go и как её DI-фреймворк Fx (внутри которого находится dig) позволяет достигать согласованности в больших масштабах проектов. Если вы — компания уровня Uber и у вас готовы все инструменты наблюдаемости, чтобы устранить все недостатки, то вам решать.

Кроме того, если вы работаете с кодовой базой, где уже используется фреймворк, и он работает хорошо, то нет особой мотивации выполнять её рефакторинг.

Или, например, вы пишете на одном из тех языков, в которых применение DI-фреймворка — это норма, и вас назовут странным, если вы попытаетесь изобретать велосипед.

Однако, по моему опыту, даже в организациях, поддерживающих большое количество репозиториев на Go, DI-фреймворки добавляют больше неудобств, чем выгоды. Если ваш опыт показывает иное, то рад буду ошибаться.

Комментарии (11)


  1. AlexEOL
    10.06.2025 10:46

    Цитата про «5-центовую концепцию» задела за живое — не могу с ней согласиться. У этой идеи есть свои корни и непростой путь реализации.

    Подмена реализаций для тестов и продакшена действительно упрощается. Но если копнуть глубже, мы упираемся в те же три столпа ООП — инкапсуляцию, наследование и полиморфизм. Проще говоря, мы просто переносим часть «магии» и повторяющегося кода внутрь IoC-контейнера.

    IoC — полезный, но нетривиальный концепт: он снимает с нас много рутины, но заставляет глубже понимать архитектуру. В простых проектах вполне можно обойтись ручным DI — это проще и быстрее. Но как только логика усложняется или появляются циклические зависимости, ручная обвязка превращается в ад.

    Не хотелось бы, чтобы разработчики чрезмерно упрощали концепцию и приходили к неправильному пониманию.

    Претензий к статье нет, но цитата Джеймса Шора зацепила :)

    Спасибо за статью — было интересно прочитать.


    1. kolebynov
      10.06.2025 10:46

      IoC не имеет отношения к DI, держу в курсе.


      1. AlexEOL
        10.06.2025 10:46

        Просто ради интереса, а чем являются dig и wire?


        1. kolebynov
          10.06.2025 10:46

          Ну быстрое гугление говорит, что это DI библиотеки для Go. При чем тут это?


          1. AlexEOL
            10.06.2025 10:46

            В предыдущем комментарии я ошибся в терминологии: под «IoC» я имел в виду IoC-контейнер. Возможно, из-за этого мой посыл был не до конца понятен.

            dig и wire - это реализации IoC-контейнеров, которые реализуют паттерн Dependency Injection.

            Конечно, можно сказать, что dig и wire - просто библиотеки, и это тоже будет правдой, но важно понимать, какой паттерн стоит за ними и глубину мысли.

            Спора нет, просто хотел убедиться, что мы говорим об одном и том же.


            1. kolebynov
              10.06.2025 10:46

              Как по мне такое должно называться DI-контейнеры (так в большинстве случаев и называют). IoC это вообще про другое, это про инверсию контроля/управления. Вот когда вы в каком-нибудь любимом веб-фреймворке добавляете хэндлеры для обработки HTTP запросов, добавляете какие-нибудь middleware и потом фреймворк это вызывает, вот это и называется IoC, т.е. поток управления инвертирован, не вы вызываете, а вас вызывают.


              1. AlexEOL
                10.06.2025 10:46

                Подписка на события не имеет отношения к IoC/IoC-контейнерам - это просто асинхронная парадигма программирования. Она встречается как в популярных веб-фреймворках, так и на уровне ОС: без неё невозможно обеспечить интерактивность и отзывчивость системы. Кажется, вы смешиваете разные вещи.

                IoC-паттерн же касается другого: наш код зависит не от конкретной реализации, а от абстракции (интерфейса), а конкретная реализация «внедряется» извне. Здесь как раз задействуется полиморфизм.

                Вы вправе называть это DI-контейнером - ваше право, - но терминология паттернов нужна для сжатой и однозначной передачи мысли собеседнику и общего понятийного аппарата.


                1. kolebynov
                  10.06.2025 10:46

                  Непонимаю, какие вы тут события нашли... Вообщем можете открыть статью на англ/русской википедии про IoC, там неплохо написано про то, чем это является.


                  1. AlexEOL
                    10.06.2025 10:46

                    я думаю разговор исчерпал себя :)


  1. MyraJKee
    10.06.2025 10:46

    Как-то в go эти DI выглядят ущербно.


  1. yrub
    10.06.2025 10:46

    я плохо представляю как можно без DI сделать гибкий фреймворк и делать на нем приложения. хотя представляю, форкаем и правим :) вообще полно разных странных людей, кто-то до сих пор утверждает что типы не нужны, ну и ради бога, пускай каждый делает как хочет и набивает свои шишки, некоторые даже испытывают удовольствие от этого