handbookНаписать

principles/solid/dependency-inversion/README.md · тема · 4 языка · 15 тестов

Принцип инверсии зависимостей (Dependency Inversion Principle) ​

Модули верхнего уровня зависят от абстракций, а не от деталей: сервис уведомлений знает порт Mailer, а SMTP - деталь, которая подключается снаружи.

Проблема ​

Сервис уведомлений сам создаёт SMTP-клиента и отправляет письмо:

php
final class NotificationService
{
    public function orderShipped(string $email, int $orderId): void
    {
        $smtp = new SmtpClient('smtp.example.com', 587, 'shop', getenv('SMTP_PASSWORD'));
        $smtp->send($email, "Order {$orderId} shipped", "Your order {$orderId} is on its way.");
    }
}

Бизнес-правило "сообщить покупателю, что заказ отправлен" намертво связано с транспортом. Тест сервиса либо шлёт настоящие письма, либо требует поднятого SMTP-сервера. Переезд на HTTP API почтового провайдера - правка сервиса уведомлений, хотя сами уведомления не поменялись. А исключения SMTP-библиотеки протекают в код, который вызывает orderShipped, и он тоже начинает зависеть от SMTP.

Решение ​

Перевернуть направление зависимости. Сервис объявляет, что ему нужно, - порт Mailer с одним методом send(Message) - и зависит только от него. Порт принадлежит слою сервиса, а не почтовой библиотеке: его форма описывает потребность уведомлений, а не возможности SMTP. Адаптеры (InMemoryMailer, LogMailer, в продакшене - SMTP) реализуют порт и зависят от него, а не наоборот.

Ошибка тоже часть порта: MailerUnavailable объявлена рядом с Mailer, и адаптер переводит в неё свои транспортные ошибки. Вызывающий код ловит ошибку порта и ничего не знает о том, что внутри.

Структура ​

NotificationService

-mailer: Mailer

+orderShipped(email, orderId)

«interface»

Mailer

+send(message)

Message

+to: string

+subject: string

+body: string

MailerUnavailable

InMemoryMailer

+sent() : Message[]

LogMailer

+lines() : string[]

Стрелки от адаптеров идут к порту, а не от сервиса к адаптерам: сервис можно собрать и протестировать без единого почтового пакета. SMTP-адаптер в теме не показан - ему нужна сеть, - но он устроен так же, как LogMailer: реализует Mailer и живёт в инфраструктурном слое.

Как это собирается ​

Какой адаптер подставить в порт, решает точка сборки - DI-контейнер или main, обычно по окружению: в продакшене SMTP или API провайдера, в разработке LogMailer, в тестах InMemoryMailer. Сервис получает адаптер через конструктор и больше о нём не думает. В примере это окружение разработки: сервис собран над LogMailer, обработчик очереди склада получает три отправленных заказа и вызывает orderShipped на каждый, а лог показывает, какие письма ушли бы покупателям.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPinterface Mailer с @throws MailerUnavailable; сервис - final readonly class, адаптер в тесте - анонимный класс
GoПорт объявлен у потребителя; Send возвращает error, сервис оборачивает его через %w; LogMailer пишет в любой io.Writer
PythonProtocol без наследования; LogMailer пишет в любой текстовый поток TextIO
TypeScriptLogMailer принимает функцию записи строки; адаптер в тесте - объектный литерал

Запуск: make test T=principles/solid/dependency-inversion

Плюсы и минусы ​

Тесты проверяют сервис без сети и без почтовых библиотек: через InMemoryMailer видно, какое письмо ушло, с какой темой и текстом. Второй тест подставляет адаптер, объявленный прямо в тесте, - "упавший" почтовый сервер, - и сервис пробрасывает ошибку порта MailerUnavailable, не зная ни о каком SMTP. Третий проверяет сам LogMailer: одна строка на письмо, тело в лог не попадает.

  • ✅ Бизнес-логика тестируется без инфраструктуры, тесты быстрые и детерминированные.
  • ✅ Транспорт меняется в точке сборки, сервис не трогается.
  • ✅ Ошибки инфраструктуры не протекают наверх: снаружи видна только ошибка порта.
  • ❌ Лишний слой: интерфейс и адаптер там, где хватило бы одного вызова библиотеки.
  • ❌ Порт легко спроектировать "под библиотеку", а не под потребность - тогда инверсия только на бумаге.

Когда не применять ​

  • Скрипт или прототип, где транспорт один и тестов не будет.
  • Зависимость стабильна и сама является абстракцией: стандартная библиотека языка, PSR-интерфейсы, io.Writer - заворачивать их в свой порт незачем.
  • Порт с единственной реализацией и без тестовых двойников - это просто лишний файл.

Отличия между языками ​

  • PHP - порт выражается интерфейсом, контейнер связывает его с реализацией по типу в конструкторе. Ошибку порта можно описать только в докблоке @throws, язык её не проверяет.
  • Go - интерфейс объявляется там, где используется, а реализация о нём не знает: SMTP-пакет может вообще не импортировать пакет сервиса. Ошибка - значение, и оборачивание %w сохраняет ErrMailerUnavailable для errors.Is. Готовая стандартная абстракция io.Writer избавляет LogMailer от собственного порта для вывода.
  • Python - Protocol описывает порт формой: адаптеру не нужно ни наследовать, ни импортировать Mailer. Внедрение - обычный аргумент конструктора dataclass, без контейнера.
  • TypeScript - структурные интерфейсы: адаптер в тесте - литерал, а LogMailer принимает функцию вместо потока, потому что в языке это самый лёгкий способ инвертировать зависимость.

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

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS