Дисклеймер: статья для разработчиков уровня junior и middle, которые знакомятся с принципами чистого кода и SOLID. Примеры кода — PHP 8.1+.
Всем доброго дня! На связи Валевич Артем — тимлид в компании AGIMA.

В первой части разобрали SRP, во второй — OCP, в третьей — LSP, в четвёртой — ISP. Кейс с отзывами к этому моменту выглядит прилично: у каждого класса своя причина меняться, новые форматы добавляются без правки сервиса, интерфейсы узкие.
И всё же однажды в FeedbackReportService появляется use App\Infrastructure\FeedbackRepository, и сценарий отчёта начинает знать конкретный класс с SQL.
С этим и работает Dependency Inversion Principle (DIP). В учебниках его обычно сводят к «передавай зависимости через конструктор» или к картинке Clean Architecture, где бизнес в центре, база снаружи, а между ними несколько слоёв папок. Ни то, ни другое не отвечает на практический вопрос: где должен лежать FeedbackReader и почему сервису отчёта нельзя видеть класс с SQL.
Главный вопрос: как развернуть стрелку зависимости и при этом не спутать DIP с контейнером и с папками для галочки?
Что на самом деле означает DIP
Принцип сформулировал Роберт Мартин в статье The Dependency Inversion Principle (C++ Report, 1996), позже он повторил его в книгах Agile Software Development и Clean Architecture:
A. High level modules should not depend upon low level modules. Both should depend upon abstractions.
B. Abstractions should not depend upon details. Details should depend upon abstractions.
На русском:
Модули верхнего уровня не должны зависеть от модулей нижнего уровня. Оба должны зависеть от абстракций.
Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций.
Часто после этого определения рассуждают так:
Значит, рисуем слои, выносим интерфейсы в отдельную папку, всё передаём через конструктор — иначе нарушим SOLID.
Принцип говорит о другом — о направлении зависимости. Обычно сценарий вызывает класс с SQL и поэтому от него зависит. DIP предлагает развернуть стрелку: сценарий объявляет интерфейс, который ему нужен, а класс с SQL этот интерфейс реализует. Вызов по-прежнему идёт от сценария к базе, но зависимость в коде теперь направлена от базы к сценарию.
Верхний уровень у Мартина — это политика, то есть шаги сценария: прочитать отзывы, собрать отчёт, отправить его. Нижний уровень — код, который ходит наружу: база, почта, Slack, генерация CSV. Сценарий знает только FeedbackReader, а FeedbackRepository знает SQL и реализует этот интерфейс.
На схеме слева сервис отчёта напрямую зависит от класса с SQL. Справа оба класса зависят от FeedbackReader: сервис его вызывает, репозиторий его реализует.
flowchart LR
subgraph bad["Сервис знает класс с SQL"]
S1[FeedbackReportService] --> R1[FeedbackRepository]
end
subgraph good["Оба зависят от интерфейса"]
S2[FeedbackReportService] -->|вызывает| I[FeedbackReader]
R2[FeedbackRepository] -.->|реализует| I
end
DIP — это не «передали в конструктор»
Есть три разные вещи, которые часто называют одним словом.
Внедрение зависимости (DI) отвечает на вопрос, как объект попал внутрь класса. Если его передали снаружи через конструктор или сеттер, это DI. Если класс сам вызвал new внутри метода, это уже не DI.
DIP отвечает на вопрос, какого типа этот объект: интерфейс, который нужен сценарию, или конкретный класс с SQL.
Контейнер отвечает за то, кто вызывает new. Это может быть Symfony, Laravel или обычный файл сборки. На то, куда смотрит тип, контейнеру всё равно.
DI | DIP | Контейнер | |
|---|---|---|---|
Вопрос | Как объект попал внутрь? | От чего зависит сценарий? | Кто создаёт объекты? |
Чем делают | Конструктор, сеттер | Интерфейс рядом со сценарием | Symfony, Laravel, |
Эти три вещи встречаются по отдельности:
конструктор ждёт
FeedbackRepository: объект передали снаружи, но стрелка всё равно смотрит на SQL;объекты собрали руками в
bootstrap.php: контейнера нет, а стрелка направлена правильно.
class FeedbackReportService
{
public function __construct(
private FeedbackRepository $repository // передали снаружи, но тип — класс с SQL
) {}
}
Контейнер создал репозиторий и передал его в конструктор, и DI здесь есть. Но передать сюда что-то, кроме репозитория, нельзя: тип не позволит. Остаётся либо наследник FeedbackRepository, у которого save() падает (от этого пути мы ушли в LSP), либо правка самого сервиса отчётов.
Если стрелка уже развёрнута, передача через конструктор получается сама собой: в сборке FeedbackRepository подставляют туда, где ждут FeedbackReader. Обратное неверно — наличие контейнера ещё не значит, что стрелка развёрнута.
Где стрелка смотрит не туда
Сценарий знает класс с SQL
namespace App\Feedback;
use App\Infrastructure\FeedbackRepository;
class FeedbackReportService
{
public function __construct(
private FeedbackRepository $repository
) {}
}
Объект пришёл снаружи, но сценарий всё равно знает конкретную базу.
Последствия те же, что в прошлых частях. Тест либо ходит в базу, либо подменяет весь FeedbackRepository, и в подмене виден save(), хотя сценарию нужен только список отзывов. Если понадобилось читать отзывы из памяти, приходится править сервис. Статический анализ тоже не поможет: если кто-то вызовет в сервисе отчёта $this->repository->save(), PHPStan промолчит, потому что такой метод у класса есть. Узкий FeedbackReader из LSP и ISP существует, но сервис его не использует.
Ещё хуже вариант, когда внутри send() прямо создают new \PDO(...). Зависимость никто не передаёт, и проверить сценарий без базы невозможно.
Нужна обратная ситуация: сценарий зависит от FeedbackReader, а SQL живёт в другом месте.
«У нас контейнер — значит, DIP уже есть»
У нас Laravel / Symfony, autowire включён — DIP соблюдён.
Autowire подставляет зависимости в конструктор по типам. Какие классы импортирует сценарий, он не проверяет.
Первая ловушка: интерфейс объявили рядом с SQL, и сценарий импортирует его оттуда.
namespace App\Infrastructure;
use App\Feedback\DateRange;
use App\Feedback\Feedback;
interface FeedbackReader
{
/** @return Feedback[] */
public function getForPeriod(DateRange $period): array;
}
class FeedbackRepository implements FeedbackReader { /* SQL */ }
namespace App\Feedback;
use App\Infrastructure\FeedbackReader;
class FeedbackReportService
{
public function __construct(private FeedbackReader $reader) {}
}
В конструкторе стоит интерфейс, но use ведёт в папку с SQL. Обратите внимание на импорты интерфейса: Infrastructure знает про DateRange и Feedback из папки отзывов, а папка отзывов — про Infrastructure. Получилась круговая зависимость, и модуль отзывов без инфраструктуры уже не собрать.
Вторая ловушка: файл интерфейса лежит где надо, но через сигнатуру в него протекла одна из реализаций.
interface ReportSender
{
public function send(Report $report, string $slackChannelId): void;
}
Отправке по почте приходится принимать id канала Slack и игнорировать его, а сценарий отчёта теперь знает про Slack. В LSP этот id передавали в конструктор SlackReportSender, и там ему самое место. Та же проблема возникнет, если общий format() начнёт принимать Spreadsheet из библиотеки XLSX.
Деталь может протечь не только через аргументы:
через возвращаемое значение — если
getForPeriod()отдаётPDOStatement, DoctrineCollectionили массив строк прямо из таблицы, сценарий начинает знать, как устроено хранилище;через исключения — если сквозь
FeedbackReaderпролетаетPDOException, а сервис его ловит, у сервиса появляетсяuseна драйвер базы.
Интерфейс описывает то, что нужно сценарию (getForPeriod, send), а принимает и возвращает типы сценария (DateRange, Feedback, Report). PDO, S3Client и SlackWebhook остаются внутри реализаций.
Папки ради папок
Так часто выглядит реакция на схему из доклада про чистую архитектуру:
src/
Domain/
Entities/
ValueObjects/
DomainServices/
Application/
UseCases/
Ports/
In/
Out/
Infrastructure/
Adapters/
Persistence/
Messaging/
Bootstrap/
Это для одной фичи, и в дереве уже четырнадцать папок, хотя ни одного класса ещё нет.
Ports в таком дереве — папка интерфейсов, Adapters — папка классов, которые ходят в базу, почту и Slack. Вскоре появляется GetFeedbackForReportUseCase, который вызывает FeedbackReaderPort. Его реализует PostgresFeedbackReaderAdapter, а заодно тащит интерфейс транзакций «на будущее».
Формально к такому коду не придраться. На практике новое поле отзыва приходится править в четырёх местах, getForPeriod() долго ищут по дереву, а интерфейсов больше, чем вторых реализаций.
Это тот же космолёт, что в SRP и OCP, только вместо лишних фабрик здесь папка Ports/Out. DIP не требует много слоёв, ему важно только направление стрелки. Для отзывов хватит двух папок.
Две папки
src/
Feedback/ # сценарии и интерфейсы, которые им нужны
Feedback.php
DateRange.php
Report.php
ReportDeliveryException.php
FeedbackReader.php
FeedbackWriter.php
FeedbackReportFormatter.php
ReportSender.php
HighRatedFeedbackReader.php # правило «только высокие оценки»
FeedbackReportService.php
FeedbackService.php
Infrastructure/ # база, почта, Slack, файлы
FeedbackRepository.php
CachingFeedbackReader.php # те же данные, только из кеша
EmailReportSender.php
SlackReportSender.php
CsvFeedbackReportFormatter.php
XlsxFeedbackReportFormatter.php
bootstrap.php
Контроллеры и консольные команды в дереве не показаны: это точки входа по HTTP и по cron, а не сценарии.
Feedback/ — папка фичи. В ней лежат сценарии, интерфейсы, которые им нужны, и типы вроде Feedback, DateRange и Report. В Infrastructure/ уходит всё, что ходит наружу.
Форматтеры сами вызывают библиотеку CSV или XLSX, и отдельный интерфейс над CsvWriter мы не заводим. Форматтер и так служит обёрткой над библиотекой, а интерфейс FeedbackReportFormatter уже есть.
Разница между HighRatedFeedbackReader и CachingFeedbackReader показывает, как выбирать папку. Первый задаёт правило выборки, то есть бизнес-логику, поэтому лежит в Feedback/. Второй возвращает те же строки, только быстрее, — это техническая деталь, и он лежит в Infrastructure/. Оба реализуют FeedbackReader из папки фичи.
Вот HighRatedFeedbackReader из LSP, теперь с указанием namespace. Он оборачивает другой FeedbackReader и ничего не знает о том, откуда берутся отзывы:
namespace App\Feedback;
final class HighRatedFeedbackReader implements FeedbackReader
{
public function __construct(private FeedbackReader $inner) {}
public function getForPeriod(DateRange $period): array
{
return array_values(array_filter(
$this->inner->getForPeriod($period),
fn(Feedback $f) => $f->rating >= 4
));
}
}
Внутри может оказаться репозиторий, кеш или чтение из памяти в тесте. Правило от этого не меняется, а в Feedback/ по-прежнему нет ни одного импорта из Infrastructure.
Ни один файл в Feedback/ не содержит use App\Infrastructure\.... Зато Infrastructure/ импортирует FeedbackReader и реализует его. Сам PHP такой импорт не запретит. Проверить свой проект прямо сейчас можно одной командой:
grep -rn 'use App\\Infrastructure' src/Feedback
Пустой вывод означает, что стрелки смотрят куда надо. Чтобы правило не нарушилось через месяц, его проверяют в CI с помощью deptrac или PHPat. Для deptrac хватит такого конфига:
deptrac:
paths:
- ./src
layers:
- name: Feedback
collectors:
- type: directory
value: src/Feedback/.*
- name: Infrastructure
collectors:
- type: directory
value: src/Infrastructure/.*
ruleset:
Feedback: ~ # папке фичи нельзя зависеть ни от кого
Infrastructure:
- Feedback # инфраструктуре можно зависеть от фичи
Интерфейс принадлежит тому, кто его вызывает. В Agile Software Development Мартин кладёт интерфейс в один пакет с вызывающим кодом, поэтому FeedbackReader лежит рядом с сервисом отчёта. Значит, ошибка из прошлого раздела была даже не в том, что интерфейс лежал рядом с SQL. Он лежал далеко от кода, который его вызывает.
То же касается исключений. ReportDeliveryException — часть контракта ReportSender, поэтому он тоже живёт в Feedback/. Если положить его рядом со SlackReportSender, то catch в сервисе потянет за собой импорт из Infrastructure. Это и есть вторая часть формулировки Мартина: абстракция не должна зависеть от деталей.
Реализация ловит ошибку своего HTTP-клиента и превращает её в исключение из контракта. Возьмём SlackReportSender из LSP и для примера будем считать, что SlackClient работает поверх PSR-18:
namespace App\Infrastructure;
use App\Feedback\Report;
use App\Feedback\ReportDeliveryException;
use App\Feedback\ReportSender;
use Psr\Http\Client\ClientExceptionInterface;
final class SlackReportSender implements ReportSender
{
public function __construct(
private SlackClient $slack,
private string $channelId,
) {}
public function send(Report $report): void
{
try {
// postMessage() или uploadFile(), как в LSP
} catch (ClientExceptionInterface $e) {
throw new ReportDeliveryException('Не удалось отправить отчёт в Slack', previous: $e);
}
}
}
Сценарий ловит ReportDeliveryException и не знает, какой HTTP-клиент стоит внутри. Исходная ошибка сохраняется в previous и попадёт в логи.
namespace App\Feedback;
class FeedbackReportService
{
public function __construct(
private FeedbackReader $reader
) {}
public function send(
DateRange $period,
FeedbackReportFormatter $formatter,
ReportSender $sender
): void {
$feedback = $this->reader->getForPeriod($period);
$report = $formatter->format($feedback);
$sender->send($report);
}
}
Чтобы в тесте заменить PostgreSQL на чтение из памяти, достаточно поменять сборку, а этот класс трогать не нужно.
С отправкой из OCP всё так же: ReportSender лежит в Feedback/, SlackReportSender — в Infrastructure/. Пока отправка нужна только отзывам, интерфейс живёт рядом со сценарием. Когда его начнут вызывать несколько фич, его можно перенести в общую папку, но не раньше.
LSP и ISP убрали save() из того, что видит отчёт. DIP отвечает на следующий вопрос: где лежит файл интерфейса. Ответ — рядом с тем, кто его вызывает.
Где это собирается
Сценарий встречается с базой только в одном месте — в файле сборки. Это может быть bootstrap.php, config/services.yaml или AppServiceProvider.
$pdo = new \PDO($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASS']);
$repository = new FeedbackRepository($pdo);
$reportService = new FeedbackReportService($repository); // ждёт FeedbackReader
$feedbackService = new FeedbackService($repository); // ждёт FeedbackWriter
// отчёт только по высоким оценкам — тот же сервис, другая сборка
$highRatedReportService = new FeedbackReportService(
new HighRatedFeedbackReader($repository)
);
Сборка знает и сценарий, и базу, а сценарий про базу ничего не знает. Отчёт по высоким оценкам собирается так же, как в LSP, и FeedbackReportService при этом не меняется.
Один FeedbackRepository реализует оба интерфейса, и обоим сервисам передаётся один и тот же объект с одним соединением. В фреймворках это делается так.
Symfony. Если интерфейс реализует ровно один сервис, Symfony при autodiscovery сам создаёт для него псевдоним, и писать ничего не нужно. Но у FeedbackReader реализаций три: репозиторий, HighRatedFeedbackReader и CachingFeedbackReader. Поэтому указываем явно:
# config/services.yaml
App\Feedback\FeedbackReader: '@App\Infrastructure\FeedbackRepository'
Laravel. Здесь легко ошибиться: bind() создаёт новый объект при каждом запросе. Чтобы оба интерфейса получили один репозиторий, сам репозиторий регистрируем как singleton:
// AppServiceProvider::register()
$this->app->singleton(FeedbackRepository::class);
$this->app->bind(FeedbackReader::class, FeedbackRepository::class);
$this->app->bind(FeedbackWriter::class, FeedbackRepository::class);
Формат и канал отправки по-прежнему выбирает cron, как в OCP: каждый день CSV уходит на почту, раз в неделю XLSX — в Slack. В конструктор сервиса мы их не кладём, потому что у каждой задачи cron они свои.
Тест без базы
Все три зависимости сервиса подменяются простыми классами из tests/. Фейковый reader запоминает, за какой период у него просили отзывы. Фейковый форматтер запоминает, какие отзывы ему передали, и отдаёт заранее подготовленный Report. Фейковый отправитель запоминает, что ему отправили. Настоящий CSV в этом тесте не создаётся.
final class InMemoryFeedbackReader implements FeedbackReader
{
public ?DateRange $requestedPeriod = null;
/** @param Feedback[] $feedback */
public function __construct(private array $feedback) {}
public function getForPeriod(DateRange $period): array
{
$this->requestedPeriod = $period;
return $this->feedback;
}
}
final class FakeFeedbackReportFormatter implements FeedbackReportFormatter
{
/** @var Feedback[] */
public array $received = [];
public function __construct(private Report $report) {}
public function format(array $feedback): Report
{
$this->received = $feedback;
return $this->report;
}
}
final class FakeReportSender implements ReportSender
{
public ?Report $sent = null;
public function send(Report $report): void
{
$this->sent = $report;
}
}
$createdAt = new \DateTimeImmutable('2026-03-10 12:00:00');
$period = new DateRange(
new \DateTimeImmutable('2026-03-10 00:00:00'),
new \DateTimeImmutable('2026-03-11 00:00:00'),
);
$feedback = [new Feedback(1, 5, 'отлично', $createdAt, 'site')];
$report = new Report('csv', 'fake-body', 'daily.csv');
$formatter = new FakeFeedbackReportFormatter($report);
$sender = new FakeReportSender();
$reader = new InMemoryFeedbackReader($feedback);
$service = new FeedbackReportService($reader);
$service->send($period, $formatter, $sender);
self::assertSame($period, $reader->requestedPeriod);
self::assertSame($feedback, $formatter->received);
self::assertSame($report, $sender->sent);
Фейки лежат в tests/, в Feedback/ им делать нечего. SQL и генерацию настоящего CSV проверяют тесты самих FeedbackRepository и CsvFeedbackReportFormatter, а тест сценария проверяет только шаги сценария.
Когда интерфейс не нужен
Не каждой зависимости нужен интерфейс.
Типы фичи. DateRange, Feedback, Report — обычные классы. Они описывают модель отзывов и не меняются, когда меняется база или Slack. DIP нужен на границе с кодом, который меняется по другим причинам.
Одна база, и тесты всё равно в неё ходят. Если отзывы всегда хранятся в PostgreSQL, отдельный тест сценария без базы никому не нужен и команда маленькая, в конструкторе может остаться FeedbackRepository. Это осознанный компромисс, но называть его DIP не стоит. Интерфейс начинает окупаться, как только сценарий нужно проверить без базы, а вторая база для этого не обязательна. Чтение с реплики тоже не повод заводить интерфейс: это тот же класс с другим соединением в сборке.
Модели ORM. В Laravel Feedback extends Model — модель сама знает свою таблицу, то есть она уже деталь. В Doctrine похожая история: атрибуты #[ORM\Entity] и #[ORM\Column] тянут в папку фичи use Doctrine\ORM\Mapping. В небольшом проекте это нормальный компромисс, ради чистоты папок не стоит переписывать всё на отдельные модели. Но о нём важно знать. Для Doctrine есть чистый выход: перенести маппинг в XML внутри Infrastructure/. Для Eloquent честнее признать, что модель — часть инфраструктуры, и не делать вид, что FeedbackReader, возвращающий Model, ничего не знает о базе. Хорошая новость в том, что тестам это почти не мешает: модель Eloquent можно создать через new Feedback([...]) без подключения к базе, и фейковый reader вернёт её точно так же.
Объекты фреймворка на входе. Использовать Request в контроллере нормально, тащить его в папку фичи незачем. Граница проходит там, где контроллер вызывает FeedbackService::submit(): сервис зависит от FeedbackWriter и про Request ничего не знает.
Прежде чем заводить новый интерфейс, ответьте на пять вопросов:
Деталь меняется независимо от сценария? SQL, почта и Slack — да. Шаги «прочитал → собрал → отправил» — нет.
Сценарий нужно проверять без этой детали? Если тест сервиса отчёта требует базу, ответ уже «да».
Есть ли в папке фичи импорт из
Infrastructure? Даже если импортируется интерфейс.Видна ли в сигнатуре интерфейса одна конкретная реализация?
slackChannelIdу общегоsend()— как раз такой случай.Вторая реализация уже есть или уже нужна? Чтение из памяти для теста считается. «Вдруг переедем на Mongo» — нет.
Что не добавляем
FeedbackReaderPortвApplication/Ports/OutвместоFeedback/FeedbackReader.php;самописную проверку импортов — с этим справятся deptrac или PHPat;
пакет
Contracts/для интерфейсов одной фичи. Общая папка нужна, когда интерфейс уже вызывают несколько фич;приставку
Iв именах и фабрики интерфейсов;интерфейс над
CsvWriterиXlsxWriter;CQRS и шину событий «на случай, если отчёт станет событийным», когда отзывов приходит десять в день.
Чем за это платим
Интерфейс при одной базе окупается, только если уже нужны тесты без неё. Заводить интерфейс «на будущее», пока второй реализации нет, — слишком рано. Именно поэтому в SRP у репозитория не было интерфейса: отзывы хранились в одной базе, других требований не было. FeedbackReader появился только в LSP, когда понадобилась честная замена.
Узкие интерфейсы из ISP стоят несколько строк в сборке: каждый нужно связать с репозиторием. Две папки при этом проще, чем Domain, Application, Ports и Adapters. Правда, язык не запрещает импортировать Infrastructure в сценарий, так что порядок держится на ревью и проверке в CI.
Можно возразить, что PHPUnit умеет подменить и конкретный FeedbackRepository: createMock() создаёт объект без вызова настоящего конструктора, и база для этого не нужна. Это правда, но у такой подмены по-прежнему есть save(), и сценарий может его вызвать. А если сделать репозиторий final, как советовали в LSP, PHPUnit подменить его уже не сможет. Останется только интерфейс.
Вывод
DIP не требует контейнера и папок Domain, Application и Ports на каждую фичу. Ему нужно одно: чтобы сценарий не зависел от деталей.
Смотрите на тип и импорты, а не только на конструктор. Autowire не исправит
use App\Infrastructureв сценарии.Интерфейс лежит рядом с тем, кто его вызывает.
FeedbackReaderживёт рядом с сервисом отчёта, а не рядом с PDO.Класс с SQL реализует интерфейс сценария. Стрелка зависимости идёт от базы к
FeedbackReader.Через интерфейс не протекают детали — ни в аргументах, ни в возвращаемых значениях, ни в исключениях.
Пока фича небольшая, хватает двух папок.
Часть | Вопрос | На кейсе отзывов |
|---|---|---|
SRP | Кто просит менять код? | Сайт, приложение и PWA — разные причины менять код; приём, хранение и отчёт — разные части |
OCP | Куда добавлять новый вариант? | Формат отчёта и канал отправки |
LSP | Можно ли подставить другую реализацию и не сломать вызывающий код? | Slack не режет CSV молча, «только чтение» не через |
ISP | Все ли методы нужны тому, кто вызывает? |
|
DIP | Кто от кого зависит? | Сценарий → интерфейс ← класс с SQL |
В итоге сервис отчёта не импортирует ничего из Infrastructure. Чтобы проверить его, хватает трёх маленьких фейков, а новый способ хранения или отправки подключается в сборке, и сам сервис при этом не меняется.
Вместо заключения к серии
Пять статей подряд мы правили один и тот же кейс с отзывами. Ни разу при этом не начинали с вопроса «какой принцип здесь применить». Каждый раз сначала появлялась конкретная боль. Продакты сайта, приложения и PWA меняли правила сбора отзывов независимо друг от друга, и SRP развёл приём, хранение и отчёт по разным классам. Каждый новый формат отчёта добавлял ветку в match внутри сервиса — помог OCP. Slack молча обрезал хвост CSV, а read-only репозиторий падал на save() в команде бэкфилла — помог LSP. Через «только читающую» ручку админки вызвали delete(), и из прода пропали отзывы — помог ISP. Тест сценария требовал базу — помог DIP. Там, где боли не было, мы оставляли код как есть: репозиторий без интерфейса в SRP, форматтер без обёртки над CsvWriter, две папки вместо четырнадцати.
SOLID — это инструмент, а не устав. Каждый принцип стоит денег: лишний класс, лишний интерфейс, лишняя строка в сборке. Платить стоит тогда, когда вы понимаете, какую проблему этим решаете. «Так написано у Мартина» или «так принято в чистой архитектуре» — плохая причина добавить слой. Изменение, которое сейчас тяжело вносить, или тест, который нельзя написать, — хорошая.
Если на ревью вы можете объяснить, от какой конкретной проблемы защищает интерфейс, принцип работает на вас. Если объяснение сводится к названию принципа, скорее всего, вы строите очередной космолёт.

Комментарии 0