Падающая сборка, и это я её просил
- среда, 16 сентября 2026 г. в 00:00:10
Три истории, у которых одно общее: всё собралось.
Два пула. Соединения к базе понадобились в двух местах, и в каждом честно вызвали NewPool(). Компилируется, работает, тесты зелёные. Обнаруживается по графику числа коннектов через месяц, когда база начинает упираться в лимит.
Не тот кэш. Есть два Cache — общий и локальный для тяжёлых выборок. В один сервис передали не тот. Типы совпадают, компилятор доволен, поведение правдоподобное: просто иногда данные свежее, чем должны быть, а иногда наоборот.
Разъехавшиеся ветки. Сборка в main() разветвлена по флагу. В одной ветке компонент собрали, в другой забыли — потому что ветки правили в разное время и разные люди. Фича включена, в конфиге всё верно, а обслуживать её некому.
«Надо было руками» — здесь не ответ. Всё перечисленное написано руками. Забыть аргумент конструктора при ручной сборке нельзя, это правда — не скомпилируется. Но никто и не забывает аргумент. Ошибаются в том, какой экземпляр передать, сколько их создать и что делать в соседней ветке, а на это у компилятора нет ни одной зацепки: типы-то верные.
Рантайм-контейнеры — fx, samber/do, моя собственная первая версия — часть этого ловят: отсутствующую зависимость видно при старте, и это лучше, чем в проде. Взамен они приносят свой класс отказов, тот самый nil pointer dereference на ручке, которую забыли протестировать, и проверяются всё равно запуском.
Сразу скажу, чем кончится: все три станут ошибкой генерации. Третья — буквально только что: генератор её не ловил, и я это заметил, пока писал эту статью. Про неё будет отдельный разговор, потому что она интереснее двух первых.
В первой статье я рассказывал, как перенёс разбор запроса из рантайма в компиляцию, во второй — как документация перестала быть вторым источником правды и стала выводом из первого. Здесь третий инструмент из той же серии, и по времени он был первым. Называется он diogen: compile-time DI для Go, без рефлексии. Читает объявления, строит граф и печатает обычный Go-код — внедрение зависимостей целиком уезжает в компиляцию, рантайм-контейнера после него не остаётся.
Руками в main(). Честный способ: всё видно, никакой магии, и аргументы конструкторов действительно проверяет компилятор. Я так писал несколько лет.
Он не падает — он тихо расходится. Три истории из начала статьи ровно про это: на сотне компонентов функция сборки перестаёт читаться целиком, ветки правятся по отдельности, а «этот кэш или тот» становится вопросом внимательности. Отказ не исчезает, он просто перестаёт быть заметным.
Контейнер в рантайме (fx, samber/do). Отсутствующая зависимость переезжает со «случайного запроса» на «старт процесса», и это большой шаг. Но проверка по-прежнему делается запуском, через рефлексию, на CI её надо специально организовать, — а «не тот экземпляр» и «два пула» он не ловит вообще, потому что с его точки зрения всё зарегистрировано верно.
wire. Компилятор проверяет то же самое, и это верный ход. Не подошёл по трём причинам, и к ним я вернусь ниже: у меня разметка зависит от условий, приходит из пакетов, которые сервис не упоминает, и закрытие ресурсов пришлось бы вписать в подписи конструкторов.
Связывание — это объявление. «Кэш реализуется редисом», «этому сервису нужен репозиторий пользователей» — утверждения, а не действия. У них нет исполнения, они просто либо согласуются между собой, либо нет.
А согласованность объявлений умеет проверять компилятор. Значит вопрос не в том, как поудобнее собрать граф в рантайме, а в том, почему проверка вообще отложена до рантайма.
И тут видно, чего в существующих моделях не хватает. Они описывают наличие: какой тип нужен и кто его производит. Три истории из начала не про наличие, они про идентичность — который из двух одинаковых по типу, при каком условии он вообще создаётся, кому предназначен. Ответы на это есть и там, но за пределами объявления: у fx строковым именем, которое не проверяет никто, у wire отдельным типом-обёрткой, заведённой ради контейнера. То есть либо строка, либо правка в вашем доменном коде, сделанная, чтобы удовлетворить сборщик.
Поэтому дальше добавлены ровно три утверждения: этот экземпляр один на всех, этому потребителю вот эта реализация, эта регистрация действует при таком условии. Всё остальное в разметке — то же, что умеет wire.
Дальше — как это выглядит.
func setup() { di.Wire[Cache](NewRedisCache) di.Wire[UserRepository](postgres.NewUserRepository) di.Define(NewUserService) }
di.Wire[T](конструктор) — «T реализуется вот этим». di.Define(конструктор) — то же, но под собственным типом, без интерфейса. Аргументы конструкторов никто не перечисляет: они и так объявлены в их сигнатурах.
Лежит это в обычном файле обычного пакета — импорт, типы, конструкторы и setup() рядом, — а запускается директивой оттуда же:
//go:generate diogen -output=internal/di/gen.go $GOFILE
Саму setup() никто не вызывает, ни здесь, ни где-либо ещё: она существует затем, чтобы её прочитал генератор.
Пакет di при этом — заглушки. Ни одна из этих функций ничего не делает и в рантайме не вызывается: они существуют, чтобы разметка была обычным Go, который проверяет компилятор, а генератор мог прочитать её как AST с полной информацией о типах.
Это, кстати, важнее, чем кажется. Опечатались в имени конструктора — ошибка компиляции, а не отсутствующая регистрация. Переименовали тип — не собирается разметка, а не «в контейнере не нашлось Cache».
И раньше компилятора это видит IDE. Разметка — обычный Go с настоящими типами, поэтому в ней работает всё штатное: автодополнение имён конструкторов, переход к определению, переименование типа сразу по всем регистрациям, поиск использований. Опечатка подчёркивается красным в редакторе — не после go generate, не после go build, а пока вы её печатаете. Для DSL в YAML или в строковых ключах не работает ничего из перечисленного.
go generate ./...
Рядом появляется файл с контейнером. Вот он для случая посложнее — с условием, и это настоящий вывод из тестового набора. Разметка:
func setup() { if env := os.Getenv("CACHE_TYPE"); env == "redis" { di.Wire[Cache](NewRedisCache) } else { di.Wire[Cache](NewMemoryCache) } }
Сгенерированное:
func InitAppWith(replacements Replacements) (_ *Application, err error) { var ( container = &Application{} env_1 = os.Getenv("CACHE_TYPE") c4e7fd691 = env_1 == "redis" ) container.conditionalCache = replacements.ConditionalCache if container.conditionalCache == nil && c4e7fd691 { s := NewRedisCache() container.conditionalCache = s } if container.conditionalCache == nil && !c4e7fd691 { s := NewMemoryCache() container.conditionalCache = s } if v, ok := container.conditionalCache.(io.Closer); ok { container.closers = append(container.closers, v) } return container, nil }
Первая строка тела — replacements. Это второй вход в приложение, из которого получается компонентный тест на настоящем графе и без инфраструктуры; про него отдельный раздел ниже.
Три места в этом выводе стоит разобрать отдельно: в каждом я выбирал форму, а не записывал единственно возможную.
Условие вынесено в переменную. os.Getenv вызывается один раз, результат складывается в env_1, а само сравнение — в c4e7fd691. Если то же условие охраняет пятнадцать компонентов, оно всё равно вычисляется однажды. Имя, разумеется, машинное: это хеш от текста выражения, и человеку его читать не надо.
Обе реализации в файле, каждая под своим условием. Не «выбор при сборке» и не «выбор рефлексией при старте», а обычный if в обычном Go. Компилятор видит оба конструктора и проверяет оба.
io.Closer собирается автоматически. Что реализует — попадёт в список, и Shutdown() закроет всё в обратном порядке создания. Это ровно тот код, который пишут руками и в котором путают порядок.
Ещё одна опция разметки, и она оказалась самой используемой. Помечаем реализации тегом, потребитель забирает их списком:
di.Wire[Handler](NewA, di.Tag("handler")) di.Wire[Handler](NewB, di.Tag("handler")) di.Define(NewRouter, di.Args(map[string]any{ "handlers": di.Tags[Handler]("handler"), }))
В контейнере из этого выходит буквально литерал среза:
s := NewRouter([]Handler{container.tagsHandlerFromNewA, container.tagsHandlerFromNewB})
Так собираются middleware, интерцепторы и команды: инструмент регистрирует себя тегом, а сервис ничего про него не знает. Обратите внимание на имена полей — tagsHandlerFromNewA. Двух регистраций одного интерфейса типом не различить, поэтому в имя добавляется конструктор.
Одну вещь про теги надо сказать прямо: это множество, а не список, и так и задумано. Порядок среза стабилен между генерациями, но контрактом не является — между пакетами он идёт по порядку blank-импортов, а их сортирует gofmt, так что попытка упорядочить цепочку перестановкой строк молча отменится при сохранении.
Это не мой недосмотр: у fx value groups устроены так же и по той же причине. Как только состав коллекции собирается из пакетов, которые друг о друге не знают, порядок перестаёт быть чьим-либо решением.
Где порядок важен — цепочка middleware, стек интерцепторов, — собирайте руками через di.Args, чтобы он лежал в одном читаемом месте:
di.Define(NewServer, di.Args(map[string]any{ "chain": []mw.Middleware{mw.Tracing, mw.Errmap, mw.Auth}, }))
Значение из di.Args генератор переносит в контейнер выражением, дословно, и сам добавляет нужный импорт. Поэтому ссылки внутри должны быть квалифицированными: контейнер обычно живёт в отдельном пакете, и голое Tracing из пакета разметки там уже ничего не значит.
Вернусь к началу. «Два пула» и «не тот кэш» — обе про экземпляры, и обе закрываются двумя опциями разметки.
Один экземпляр под несколькими интерфейсами — di.Alias. Компонент реализует два интерфейса, потребители просят каждый свой, объект нужен один:
di.Define(NewAuthService, di.Alias[Authenticator](), di.Alias[Authorizer]()) di.Define(NewUserController) // ему нужен Authenticator di.Define(NewAdminController) // ему нужен Authorizer
В контейнере:
if container.aliasAuthenticator == nil { s := NewAuthService() container.aliasAuthenticator = s container.aliasAuthorizer = s }
Конструктор вызван один раз, полей два, s в обоих одно и то же. Это и есть ответ на «два пула»: не «не забудьте переиспользовать», а «второго экземпляра тут взяться неоткуда». А зарегистрировать тот же конструктор второй раз генератор не даст: два провайдера на один слот — это ошибка ambiguous providers из следующего раздела, и в ней перечислены оба места регистрации с номерами строк.
Кому какой экземпляр — di.For. Две реализации одного интерфейса, и одна из них предназначена конкретному потребителю:
di.Wire[UserRepo](NewDefaultRepo) di.Wire[UserRepo](NewAdminRepo, di.For[AdminService]()) di.Define(NewAdminService) di.Define(NewProfileService)
В контейнере два поля, и раздаются они не по внимательности:
s := NewAdminService(container.for_contextualUserRepoForFor_contextualAdminService) ... s := NewProfileService(container.for_contextualUserRepo)
Имена полей несут префикс пакета, поэтому в тестовом пакете for_contextual они такие длинные; смысловая часть — суффикс For….
Но главное здесь не di.For, а то, что без него не собирается. Два провайдера одного слота, чьи условия не исключают друг друга, — ошибка генерации, и одна из подсказок в ней ровно про di.For. То есть «в системе два Cache» перестаёт быть фоновым фактом, о котором надо помнить: генератор требует сказать, чем они различаются, — условием, di.For или приоритетом. «Не тот кэш» не в том, что человек невнимателен, а в том, что раньше его никто не спрашивал.
Обе истории от этого меняют вид: не «будьте аккуратнее», а «скажите, что вы имели в виду, иначе не соберётся». Третья, как выяснится, того же вида — но до неё я дойду через два раздела.
Обещанные три причины. Всё, что выше, компилятор проверяет и у wire — он от Google, появился сильно раньше, и начинал я именно с него.
Первая причина — условия. У нас на одной кодовой базе живут две площадки маркетплейса: кодовая база одна, приложение одно, а PROJECT=gws и PROJECT=dl поднимают его в разных режимах — разные наборы сервисов, местами разные реализации одних и тех же интерфейсов. Отсюда, кстати, и третья история из начала статьи: ветки правятся по отдельности именно потому, что их две.
У wire нет понятия рантайм-условия. Выразить это означало бы отдельный инжектор на каждую комбинацию флагов — а комбинаций больше, чем площадок.
Вторая причина — blank-импорты. Фреймворковый пакет привозит собственную разметку, а сервис подписывается одной строкой:
import ( _ "gitlab.com/gorib/waffle/config/service/sql" _ "gitlab.com/gorib/waffle/config/tools/http" )
Генератор читает AST импортированного пакета и вливает его регистрации в контейнер. В wire потребитель обязан назвать каждый provider set, который берёт, — значит добавление инструмента правится и в инструменте, и в каждом сервисе, который его использует. При двух десятках сервисов это ощутимо.
Третья причина мельче, но именно она в своё время меня и остановила: закрытие ресурсов. У wire cleanup есть, и порядок он держит сам — но это часть подписи провайдера: чтобы компонент закрывался, конструктор должен возвращать (T, func(), error). То есть на каждый закрываемый компонент правится конструктор, и правится ровно затем, чтобы удовлетворить контейнер. Здесь вместо этого проверяется тип готового значения — реализует io.Closer, попадёт в список — и конструкторы не знают, что живут в контейнере. На 1246 регистрациях разница между «ничего не трогаем» и «дописываем возврат в каждый второй конструктор» решает.
Все три об одном и том же: то, чего модель не выражает, доплачивается кодом — отдельным инжектором на комбинацию, перечислением наборов у потребителя, возвратом в подписи конструктора.
Все три возражения — про мой проект: именно они меня тогда и остановили. Разница в возможностях шире — из показанного выше di.For в wire не выражается, а коллекций по тегу там нет вовсе, они есть у fx. Зато di.Alias отличием не является: wire.Bind делает ровно то же.
Вот на чём перенос отказа окупается: каждая ошибка ниже — проблема, которая раньше находилась запуском.
Сообщения ниже настоящие, но я обрезал в них пути: в жизни там полные import path типов и абсолютные пути файлов.
Зависимости нет:
graph error: dependency for db (DB) in Greeter is not found
Цикл:
graph error: circular dependency: A → B → A
Провайдеров несколько, и их условия не исключают друг друга:
resolve providers: ambiguous providers for Cache: NewMemoryCache (setup.go:21), NewRedisCache (setup.go:22) — pick one or distinguish with di.For / mutually exclusive conditions
Обратите внимание на оговорку про условия: два провайдера одного слота — норма, если их условия исключают друг друга. if/else — очевидный случай, но два отдельных if с enabled() и !enabled() тоже считаются исключающими. Ошибка только когда они могут сработать оба.
Одна регистрация написана дважды, и обе живы:
NewTracing (setup.go:21) is registered twice and both registrations are built: they would share the container field interceptorFromNewTracing and the second would overwrite the first — register it once, or give the second registration its own constructor
Тегированную регистрацию просят как обычную зависимость:
graph error: dependency for h (Handler) in *Audit is provided only by tagged registrations (NewA (setup.go:29), NewB (setup.go:30)): a di.Tag registration is a collection member — collect them with di.Tags, or drop the tag to wire it directly
Последняя мне нравится больше всех, потому что это не «нет провайдера». Провайдеры есть, но они участники коллекции, и подставлять одного из них как единственную реализацию — почти наверняка не то, что человек имел в виду. Раньше он получил бы либо непонятное «не найдено», либо случайного участника.
Все эти сообщения приходят от go generate, то есть до коммита.
Провайдер под условием, потребитель без условия:
if os.Getenv("FEATURE") == "on" { di.Wire[Cache](NewCache) } di.Define(NewService) // а ему Cache нужен всегда
Собирается, и в контейнере NewService(container.cache) вызывается безусловно. При выключенной фиче — с nil. Ровно тот класс, за который отвечает генератор: компилируется и неверно.
Почему я этого столько времени не видел — потому что у меня ветка без реализации по традиции закрыта паникой. Если конфигурация такая, дальше жить незачем:
if os.Getenv("CACHE_TYPE") == "redis" { di.Wire[Cache](NewRedisCache) } else { di.Panic(errors.New("CACHE_TYPE must be redis")) }
Пока пишешь так, дырки нет — она есть у того, кто так не пишет. А тут я, набирая пример для этой самой статьи, отвлёкся на середине, вернулся к файлу и else дописывать не стал. Запустил генератор — он молча отдал контейнер. Удивился: по логике всего остального он был обязан отказаться. Полез проверять — отказываться он не умел вообще.
Теперь умеет, и первым, кому отказал, был тот самый недописанный файл:
dependency for cache (Cache) in NewService (setup.go:25) is unset when os.Getenv("FEATURE") == "on" is false: the registrations that fill it are all conditional and the branches do not cover each other — add an else branch, an unconditional provider, or di.Panic to declare the gap
Проверка не гадает. Каждое условие — один булев атом, гард регистрации — конъюнкция атомов, и поле покрыто, если дизъюнкция гардов его провайдеров выполняется везде, где выполняется гард потребителя. Атомов вокруг одного поля единицы, так что это перебор присваиваний, а не SMT. Закрыть ветку можно тремя способами: else, безусловный провайдер (включая di.Fallback) или та самая di.Panic — паники выполняются раньше всех конструкторов, поэтому с nil ничего собрано не будет.
Попутно выяснилось, что комплементарность генератор до этого видел только у пары if/else — сравнивались узлы AST. Два отдельных if enabled() и if !enabled() он считал независимыми и падал на них с ambiguous providers, хотя они исчерпывающие. Теперь сравнение по каноническому литералу: !x снимается, != сворачивается в ==. Одной ложной ошибкой меньше.
Чего проверка не делает — не рассуждает о значениях за условиями:
if mode == "a" { di.Wire[Cache](NewA) } if mode == "b" { di.Wire[Cache](NewB) }
Два независимых булева, и генератор сообщит о дырке. Это не ложное срабатывание: mode == "c" действительно оставляет nil. Закрывается всё тем же else или паникой.
Компонентному тесту обычно нужен настоящий граф и два-три фейка. Для этого генерируется вторая точка входа:
func InitApp() (*Application, error) // = InitAppWith(Replacements{}) func InitAppWith(replacements Replacements) (*Application, error)
Replacements — структура с полем на каждый подменяемый слот. Подставили значение — конструктор, который его построил бы, не вызывается вовсе:
app, err := diogen.InitAppWith(diogen.Replacements{ PostgresUserRepository: fakeUserRepository{}, })
Проверяет это компилятор. Тип поля — тот интерфейс, под которым значение зарегистрировано, поэтому фейк, у которого пропал метод, не соберётся на строке, где его подставляют. Ни одного приведения типа в этом механизме нет, и это его единственный смысл: подмена, которая может упасть в рантайме, для теста бесполезна.
Что это заменяет. Обычный способ собрать такой тест — второй граф: отдельный набор провайдеров или отдельная функция сборки, где вместо базы стоит фейк. Он тоже проверяется компилятором, но его надо поддерживать, и расходится он молча: продакшен-граф поехал, тестовый остался прежним, тест зеленеет на схеме, которой больше нет. Здесь второго графа нет — берётся production-сборка, и в ней называются слоты.
Отсюда два следствия, которых у отдельного тестового графа не бывает. Подмена выигрывает у условий: поле заполняется до того, как пробуются ветки, и каждая ветка сторожится тем, что оно ещё пусто, — фейк доедет до потребителя независимо от того, какую ветку выбрала бы разметка. И закрывается значение на своём месте в графе: шов стоит там же, где стояли регистрации, поэтому подменённое, зависящее от настоящих, закроется раньше них, как того требует Shutdown.
Замеры, раз уж речь про них: на 25 сервисах двух платформ (1246 регистраций) шов получают 91% регистраций, не считая теговых. Остальные 9% выставляют наружу конкретный тип вместо интерфейса — и это лечится разметкой, а не генератором.
У шва есть второе свойство, и оно важнее первого: вместе с конструктором пропадает и конфигурация, которую он читал.
Заметьте, где стоит выражение из di.Args. Разметка:
di.Wire[Db](NewDb, di.Args(map[string]any{ "dsn": os.Getenv("DB_DSN"), })) di.Define(NewRepo)
Контейнер — здесь я укоротил имена полей, префикс пакета к делу не относится:
container.db = replacements.Db if container.db == nil { s := NewDb(os.Getenv("DB_DSN")) container.db = s }
os.Getenv — внутри охраняемого блока. Значит подмена снимает не только вызов конструктора, но и чтение его переменных: подставили Replacements{Db: fake} — и DB_DSN никому не нужен. Не «нужен, но пустой», а не читается вовсе.
Значит компонентный тест пишется без единого t.Setenv:
app, err := diogen.InitAppWith(diogen.Replacements{ Db: fakeDb{}, Cache: fakeCache{}, })
Подставили фейки на листья — и конфигурация настоящей базы никого не интересует, потому что читать её некому. Ни фиктивных DSN в тесте, ни переменных в пайплайне.
Заодно это отвечает на вопрос, который у Replacements возникает сразу: зачем шов, если у меня нет компонентных тестов. Затем, что это не тестовый механизм, а второй вход в приложение, и воспользоваться им можно из двух мест — из теста или из отдельного бинарника. Генерация документации оказалась первым применением, до которого я додумался не сразу.
И там же обнаружилась приятная деталь. Тест, который пишет две спецификации, табличный — по строке на роутер. А роутеров два потому, что они разъехались по отдельным полям контейнера через di.For[CustomerCommand]() и di.For[AdminCommand](): та же опция, которой выше раздавался net.Addr. Деление документации выпало из разметки само, фильтровать маршруты не пришлось.
Цена одна, и она про точку входа: сгенерированный InitDi() зовёт InitApp() без подмен, так что докозапуску нужен свой вариант с InitAppWith. Дописывается он, впрочем, в рукописный адаптер рядом с контейнером, а не в сам контейнер.
Частичной сборки одного контейнера. InitAppWith собирает всё или падает, попросить «только эту ветку графа» нельзя.
Зато можно иметь несколько контейнеров. Генератор читает пакет, в котором лежит входной файл, — значит два di.go в разных пакетах, каждый со своей директивой, дают два независимых Application со своими InitApp и Shutdown. Общее кладётся в модуль, который оба подключают blank-импортом:
config/ ├── shared/ // регистрации, нужные обоим ├── api/ // свой контейнер └── worker/ // свой контейнер
Это и есть ответ на «а если у меня два бинарника с разными графами»: не резать один граф, а объявить два.
Обрезки под подменой. Подменили компонент — его собственные зависимости всё равно сконструируются. Граф статический, обрезки по достижимости в рантайме нет: подменив репозиторий, вы получите фейк в контейнере и настоящий пул соединений рядом с ним. Исключение одно — коллекция по тегу: она подменяется целиком, и конструкторы её членов не вызываются, если те больше никуда не идут.
Меня это не беспокоит, и вот почему. Конструктор, который при вызове куда-то подключается, ломается и без всякого DI: его нельзя позвать в тесте, нельзя позвать дважды и нельзя позвать, пока сети нет. Поэтому у меня конструкторы ничего не делают — вот весь newPgx из SQL-слоя:
func newPgx(dsn string) Db { return &pgxDb{ dsn: dsn, driver: driver.NewPostgres(), } }
Пул поднимает отдельный Connect(ctx), который зовёт жизненный цикл приложения. Отсюда и берётся дешёвый докозапуск из раздела выше: подменять надо листья, а не тех, кто их использует, и тогда обрезка не нужна вовсе.
Но это соглашение, а не гарантия. Генератор не умеет проверить, что ваш конструктор лёгкий, и тяжёлый вызовет не поморщившись. Если он у вас тяжёлый — ставьте шов на него самого, потому что больше его никто не остановит.
Аргументов у точки входа. InitApp() не принимает ничего — всё, что попадает в граф, это выражение, которое генератор впишет в код. Конфигурацию это не ограничивает, но задаёт форму: её передают выражением в di.Args, а не провайдером. Разница принципиальная — выражение попадает внутрь охраняемого блока и исчезает вместе с подменённым компонентом, как в разделе выше, а провайдер это обычный узел, он строится всегда. Остаётся узкое: значение, которое процесс не может получить сам, и два контейнера, различающиеся таким значением в рантайме — wire для этого зовут дважды с разными аргументами, здесь два контейнера объявляются статически.
Шва у конкретных типов. di.Define(NewServer) выставляет *Server, и никакой фейк не может быть *Server. Хотите подменяемости — объявите интерфейс.
И общее: это ещё один кодогенератор в сборке. Шаг go generate, файлы в репозитории, инструмент, который надо уметь. Времени в этом списке нет: на втором по размеру нашем сервисе полный прогон занимает секунду на тёплом кэше сборки — вся тяжесть в разборе пакетов, и он параллельный. Если у вас двадцать компонентов и одна конфигурация, всё это не стоит своих денег.
Три инструмента, три разные задачи — и один и тот же ход.
Разбор запроса был кодом, стал объявлением, и опечатка в теге превратилась в ошибку сборки. Документация была вторым источником правды, стала выводом из первого, и расхождение превратилось в ошибку согласования. Связывание было последовательностью действий в main(), стало набором утверждений, и забытая зависимость превратилась в ошибку генерации.
Каждый раз одно и то же: найти в коде место, где на самом деле написано объявление, и перестать исполнять его. Дальше проверку берёт на себя тот, кто и должен, — компилятор.
Это не про Go и не про кодогенерацию. Это про то, что отказ стоит тем дешевле, чем раньше он случился, и что у большинства рантайм-проверок есть версия, которая случается раньше. Иногда её видно сразу, иногда — только когда садишься объяснять свой код чужому человеку.
За три статьи я так нашёл четыре собственных недоделки, и самую крупную — в этой, про которую вы прочитали выше. Рекомендую способ.
Есть и четвёртый инструмент, и он в этой компании белая ворона: пишет файл один раз и больше никогда в него не заглядывает — ровно наоборот к трём предыдущим.
Код: gitlab.com/gorib/di, генератор diogen. Контейнеры в листингах — дословный вывод генератора для случаев из testdata/cases/ того же репозитория; в сообщениях об ошибках обрезаны пути, всё остальное как есть. Можно сверить, а не поверить.