Принцип инверсии зависимостей (Dependency Inversion Principle)
Модули верхнего уровня зависят от абстракций, а не от деталей: сервис уведомлений знает порт
Mailer, а SMTP - деталь, которая подключается снаружи.
Проблема
Сервис уведомлений сам создаёт SMTP-клиента и отправляет письмо:
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, и адаптер переводит в неё свои транспортные ошибки. Вызывающий код ловит ошибку порта и ничего не знает о том, что внутри.
Структура
Стрелки от адаптеров идут к порту, а не от сервиса к адаптерам: сервис можно собрать и протестировать без единого почтового пакета. SMTP-адаптер в теме не показан - ему нужна сеть, - но он устроен так же, как LogMailer: реализует Mailer и живёт в инфраструктурном слое.
Как это собирается
Какой адаптер подставить в порт, решает точка сборки - DI-контейнер или main, обычно по окружению: в продакшене SMTP или API провайдера, в разработке LogMailer, в тестах InMemoryMailer. Сервис получает адаптер через конструктор и больше о нём не думает. В примере это окружение разработки: сервис собран над LogMailer, обработчик очереди склада получает три отправленных заказа и вызывает orderShipped на каждый, а лог показывает, какие письма ушли бы покупателям.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | interface Mailer с @throws MailerUnavailable; сервис - final readonly class, адаптер в тесте - анонимный класс |
| Go | Порт объявлен у потребителя; Send возвращает error, сервис оборачивает его через %w; LogMailer пишет в любой io.Writer |
| Python | Protocol без наследования; LogMailer пишет в любой текстовый поток TextIO |
| TypeScript | LogMailer принимает функцию записи строки; адаптер в тесте - объектный литерал |
Запуск: 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принимает функцию вместо потока, потому что в языке это самый лёгкий способ инвертировать зависимость.
Связанные темы
- SOLID - все пять принципов
- Принцип открытости/закрытости - зависимость от абстракции и делает систему открытой для расширения
- Адаптер - как SMTP-библиотека подключается к порту
- Сервисный слой - слой, который владеет портами и вызывает их
- Репозиторий - тот же приём для хранения данных