Инверсия зависимостей и внедрение зависимостей

Зачем это на интервью

DIP и DI часто смешивают. На интервью E4 должен разделить принцип проектирования и технику сборки объектов: DIP отвечает, от какого контракта зависит бизнес-код; DI отвечает, как конкретная зависимость оказывается в объекте. Это позволяет обсуждать тестируемость, замену HTTP-клиента, транзакции и lifecycle без обещаний «всё замокать».

Минимум для E4

  • Сформулировать разницу между DIP, DI и service locator.
  • Объявить узкий интерфейс возле прикладного потребителя, а реализацию оставить в адаптере.
  • Передать обязательные зависимости через конструктор и проверить их на старте.
  • Учитывать context.Context, ошибки, таймауты и владение ресурсом в контракте.

При прямой зависимости use case от *sql.DB бизнес-слой знает деталь хранения. Инверсия создаёт порт, выражающий нужную операцию, например загрузку пользователя. Адаптер SQL реализует этот порт; composition root соединяет их в main.

type UserFinder interface {
    FindByID(ctx context.Context, id string) (User, error)
}
 
type GetProfile struct {
    users UserFinder
}
 
func NewGetProfile(users UserFinder) (*GetProfile, error) {
    if users == nil {
        return nil, errors.New("users is required")
    }
    return &GetProfile{users: users}, nil
}

Инъекция через конструктор делает зависимость явной и проверяет nil-интерфейс до создания объекта. Она не распознаёт интерфейс с typed-nil pointer: composition root должен не присваивать такой указатель интерфейсу, а конструкторы конкретных адаптеров должны проверять свой pointer до возврата. Полевая инъекция в Go обычно хуже: она допускает nil и скрывает обязательность. Параметрическая инъекция подходит для зависимости, нужной только одному методу, например Clock или генератора ID.

Углубление для E5/Senior

E5 проектирует composition root и жизненный цикл. Долгоживущие клиенты, пулы соединений и конфигурация создаются один раз на границе процесса; request-scoped данные передают через context.Context или явные параметры, но не кладут в singleton. Конструктор не должен делать сетевой I/O: иначе сборка графа становится неуправляемой и плохо тестируется.

Граница порта должна отражать доменную потребность, а не копировать API поставщика. PaymentGateway.Charge может возвращать доменный результат и классифицированные ошибки, тогда смена провайдера не протекает в use case. Но не прячьте каждую библиотеку за интерфейсом заранее: внешний вызов, который не надо подменять и который имеет один сценарий, можно использовать напрямую до появления причины.

Ключевые понятия

ТерминСмыслПризнак здорового применения
DIPПолитика зависит от абстракцииКонтракт выражает нужду клиента
DIПередача готовой зависимости извнеКонструктор показывает обязательные зависимости
Composition rootМесто сборки графа объектовОбычно main или bootstrap-пакет
Port/adapterКонтракт и его инфраструктурная реализацияАдаптер не диктует доменную модель
Service locatorГлобальный поиск зависимостиСкрывает зависимости и затрудняет тесты

Контракт обязан описывать ошибки. Для отсутствующей записи различайте ожидаемое состояние (ErrNotFound) и инфраструктурную ошибку; вызывающий код должен понимать, что ретраить, а что превратить в ответ 404.

Типовые вопросы

  1. DIP и DI — одно и то же?
    • Нет. DIP — направление зависимостей через абстракцию; DI — способ передать реализацию.
  2. Где объявлять интерфейс в Go?
    • Обычно в пакете потребителя и только с методами, которые ему нужны.
  3. Почему service locator часто вреден?
    • Зависимости не видны в API типа, ошибка проявляется поздно, а тесты делят глобальное состояние.
  4. Нужен ли интерфейс вокруг каждой базы и SDK?
    • Нет. Он нужен при сменяемом поведении, тестовой границе или защите доменного кода от нестабильного API.
  5. Как передать транзакцию без протекания *sql.Tx в домен?
    • Описать нужный репозиторный или unit-of-work контракт в прикладном слое и реализовать его в инфраструктуре.
  6. Почему context.Context не используют как контейнер зависимостей?
    • Его значения не видны в сигнатуре по ключам, их типовая безопасность слабее, а lifecycle скрыт.

Практика

  • Переделайте use case, который вызывает http.Client и парсит ответ поставщика внутри бизнес-метода.
    • Критерии готовности: у use case есть узкий порт; адаптер переводит transport-ошибки; конструктор отклоняет nil; unit-тест не использует сеть.
  • Соберите приложение в main с конфигурацией, клиентом, адаптером и use case.
    • Критерии готовности: только bootstrap знает конкретные типы; закрытие ресурса происходит в одном месте; request-данные не являются глобальными.

Частые ошибки и ловушки

  • Путать передачу зависимостей с автоматическим соблюдением DIP.
  • Экспортировать интерфейсы «на всякий случай» из пакета реализации.
  • Превращать DI-контейнер в service locator внутри прикладного кода.
  • Хранить пользователя, request ID или транзакцию в singleton.
  • Скрывать все ошибки адаптера и лишать вызывающий код решения о retry.

Связанные темы

Источники