SOLID в реальном мире. ISP без микроинтерфейсов

Дисклеймер. Статья для разработчиков уровня junior и middle, которые знакомятся с принципами чистого кода и SOLID. Примеры кода — PHP 8.1+.
Всем доброго дня! На связи Валевич Артем — тимлид в компании AGIMA.
В первой части серии мы разобрали Single Responsibility Principle и предупредили про «охоту за интерфейсами»: один метод — один файл, а бизнес об этом не просил. Во второй — Open-Closed Principle: появились FeedbackReportFormatter, ReportSender, две оси расширения. В третьей — Liskov Substitution Principle: честная подстановка и сплит FeedbackReader / FeedbackWriter.

На code review всё красиво. Интерфейсы на месте, DI подключает реализации.
А потом для админки делают один интерфейс на всё: модерация, выгрузка, синхронизация с CRM. Для аналитики рядом вешают ручку «только чтение» — лишние методы не дёргаем, договорились. Через полгода из этой же ручки вызывают delete(). Метод был в том же интерфейсе, задача горела, в ревью это легко пропустить. В пятницу вечером из прода пропадают отзывы.
Или наоборот: кто-то прочитал про Interface Segregation и за час распилил нормальный интерфейс по одному методу на файл — хотя вызывает его один сервис.
Сегодня — Interface Segregation Principle (ISP, принцип разделения интерфейсов). В учебниках его часто сводят к «делите интерфейсы на мелкие куски». На практике это превращается в соревнование: кто создаст больше файлов с одним методом.
Пример показательный. И бесполезный, если у вас один клиент и один сценарий.
В LSP сплит Reader/Writer нужен был, чтобы подстановка не врала. Сейчас та же история с другой стороны: модератору не нужен syncToCrm(), аналитику не нужен delete() — и в интерфейсе этих методов быть не должно.
Главный вопрос: как применять ISP в реальном коде — и не превратить проект в зоопарк микроинтерфейсов?
Что на самом деле означает ISP
Принцип сформулировал Роберт Мартин в статье The Interface Segregation Principle (C++ Report, август 1996; позже — Agile Software Development). Классическая формулировка:
Clients should not be forced to depend upon interfaces that they do not use.
На русском привычно:
Клиенты не должны быть вынуждены зависеть от интерфейсов, которыми они не пользуются.
Там же — и в Agile Software Development — мысль клиентская: жирный класс может существовать, но клиенты не должны знать о нём как о едином типе. Им показывают узкие абстракции под свой набор операций.
В Clean Architecture главу про ISP легко прочитать как историю про статическую типизацию. Мартин тут же показывает: лишняя зависимость вредна и когда вы тащите модуль целиком. В PHP без type hints это почти не видно. Как только появляются подсказки типов и PHPStan — лишние методы в интерфейсе снова начинают мешать.
У многих после определения получается:
Значит, режем интерфейсы, пока в каждом файле не останется один метод.
Нет. ISP не про число файлов и не про пять implements вместо одного.
ISP про зависимости клиента. Жирный интерфейс — когда в нём больше методов, чем нужно клиенту. Если сервис только читает отзывы, ему не нужен save(). Если админка только показывает список — ей не нужен syncToCrm().
В PHP лишний метод сам по себе прод не роняет: рантайм не запретит вызвать его у объекта. Но в IDE и PHPStan клиент выглядит так, будто ему можно писать и удалять. И кто-нибудь однажды вызовет то, что «уже есть в интерфейсе».
ISP и OCP — разные вопросы
Путаница частая, потому что оба принципа живут рядом с interface.
В OCP-части мы искали ось изменений и выделили FeedbackReportFormatter и ReportSender, когда появились реальные варианты форматов и каналов. Интерфейс для расширения можно ввести честно — и всё равно оставить клиенту лишние операции.
FeedbackReportFormatter с одним методом format() для единственного клиента FeedbackReportService — узкий интерфейс. FeedbackRepository с save() и getForPeriod() для клиента, который только читает — широкий.
OCP добавляет новые варианты рядом, не трогая стабильный сценарий. ISP смотрит, не зависят ли клиенты от методов, которыми не пользуются.
Кейс: отзывы после OCP и LSP
Тот же продукт: три канала — сайт, мобильное приложение, PWA. Отзывы лежат в БД. Раз в неделю — XLSX на email, плюс ежедневный CSV для аналитиков. Часть отчётов уходит в Slack.
После OCP-части у отчётного сценария уже есть узкие интерфейсы формата и доставки. В LSP зависимость от хранилища сузили до чтения — сервис выглядит так:
interface FeedbackReportFormatter
{
/** @param Feedback[] $feedback */
public function format(array $feedback): Report;
}
interface ReportSender
{
/**
* Доставляет полный отчёт. Ошибки внешней среды — через
* согласованный тип, а не через произвольные исключения реализации.
*
* @throws ReportDeliveryException
*/
public function send(Report $report): void;
}
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);
}
}
До LSP в конструкторе стоял жирный FeedbackRepository — с save() и getForPeriod(). Тело send() не менялось; поменялась одна строка типа зависимости. Период по-прежнему снаружи — cron знает расписание.
formatter и sender приходят аргументами метода, а не полями конструктора: в OCP-части это про то, что канал разный у каждой job — одна шлёт в Slack, другая в email. На то, какие методы вызывает сервис, это не влияет.
FeedbackReportFormatter и ReportSender для сервиса уже узкие: он вызывает ровно те методы, которые в интерфейсах. Дальше появятся админка, API для аналитики, CRM.
Persistence по-прежнему один класс. Для БД оба метода уместны; для клиентов — нет:
class FeedbackRepository
{
public function __construct(private \PDO $pdo) {}
public function save(Feedback $feedback): void
{
// запись в БД
}
public function getForPeriod(DateRange $period): array
{
// выборка за период
}
}
Админский список и FeedbackService из SRP-части всё ещё сидят на этом классе целиком. Отчётный сервис — уже нет. Дальше админка обрастает сценариями, и появляется соблазн «собрать всё админское в один контракт».
Антипаттерн 1: God-interface для админки
Админка растёт. Модератору нужны просмотр и удаление спама. Аналитику в админке — выгрузка за период. Интеграции с CRM — отдельный фоновый job. На ревью звучит разумно: зачем плодить типы, если админка одна. Один сервисный контракт — проще подключать контроллеры, один биндинг в DI, один мок в тестах.
interface FeedbackAdminOperations
{
public function getForPeriod(DateRange $period): array;
public function delete(int $id): void;
public function export(DateRange $period, string $format): Report;
public function syncToCrm(DateRange $period): void;
}
Админские операции живут рядом, продукт один. Выгрузку (export) свалили в тот же контракт — вместе с удалением и CRM.
Контроллер модерации получает весь FeedbackAdminOperations:
class ModerationController
{
public function __construct(
private FeedbackAdminOperations $ops
) {}
public function list(Request $request): Response
{
$period = $this->periodFromQuery($request); // DateRange из query from/to
$items = $this->ops->getForPeriod($period);
// delete и syncToCrm здесь не нужны — но зависимость есть
return new JsonResponse($items);
}
}
Рядом — API для аналитики, тоже «только чтение», на том же интерфейсе. delete() оттуда не вызывают. Но метод уже есть — вместе с записью и синхронизацией.
Через пару месяцев это уже не теория:
тесты модерации мокают syncToCrm(), хотя сценарий его не касается;
случайный вызов delete() из «удобного» общего контракта — вопрос времени;
CRM-job тянет delete() и export(), хотя ему нужен только syncToCrm().
В тестах списка это выглядит так:
$ops = $this->createMock(FeedbackAdminOperations::class);
$ops->method('getForPeriod')->willReturn([]);
$ops->expects($this->never())->method('delete');
$ops->expects($this->never())->method('syncToCrm');
PHPUnit не требует never() на каждый неиспользуемый метод. Если такая проверка всё же появилась — в интерфейс попало то, чем сценарий не пользуется. Проще не класть эти методы в интерфейс. Мок тогда пишется по тому, что клиент вызывает, а не по широкому «админскому» интерфейсу и не по классу-реализации.
Persistence-класс с save() и getForPeriod() хотя бы про одну БД. Здесь в одном контракте уже модератор, аналитик и CRM-job. Размер файла тут ни при чём.
Оборотная сторона — заглушки в реализации: класс пишет throw или пустой метод под операции, которые ему не нужны. У FeedbackRepository заглушек в implements нет: он честно умеет и читать, и писать. Но ReadOnlyFeedbackRepository из LSP-части с throw в save() — тот же симптом: контракт шире, чем нужно клиенту, и реализацию заставляют «закрыть» лишнее.
Антипаттерн 2: микроинтерфейсы
Обратная крайность — реакция на ISP из первой части. Разработчик прочитал «клиенты не должны зависеть от лишних методов» и начал дробить уже узкое. Берут FeedbackReportFormatter с одним format() — и режут дальше:
interface IFormatable
{
/** @param Feedback[] $feedback */
public function format(array $feedback): Report;
}
Я вижу это на ревью: форматтер уже из одного метода, а рядом появляется IFormatable — то же имя с буквой I. CsvFeedbackReportFormatter его реализует. Второго клиента с другим набором вызовов нет. Добавить поведение в отчётный pipeline — править лишний тип и все implements. Новый человек в команде видит не форматтер, а набор операций с буквой I.
Дробить нечего: один метод, один клиент (FeedbackReportService), клиент всегда вызывает format() ровно один раз. То же было бы с ISendable вместо ReportSender.
Это конфликт ISP с YAGNI и KISS. Принцип не требует максимального дробления. Он требует достаточного: клиент не тащит лишние операции. Если набор вызовов у клиентов не расходится — один интерфейс с одним методом уже узкий.
В OCP-части мы ввели эти интерфейсы, когда появились реальные варианты реализаций — CSV и XLSX, email и Slack. Перепиливать их на микрокуски без нового клиента незачем.
Микроинтерфейсы без разных клиентов с разным набором вызовов — архитектурный космолёт в миниатюре. Только вместо фабрик — зоопарк буквы I.
А если FeedbackReader обрастёт методами?
Сейчас у него один метод — getForPeriod(). Вынести его в IGetForPeriod бессмысленно: это то же самое под другим именем.
Потом модерации понадобится поиск, виджету — последние отзывы, дашборду — countForPeriod. Резать уже есть что. Не «по методу».
Отчёты, выгрузка и API аналитики по-прежнему берут отзывы за период. Для них это один FeedbackReader. Модерации нужны findById, поиск по тексту, фильтр по статусу — и меняется это отдельно от отчётов. Виджету — getLatest, и он живёт своей жизнью. Тогда появляются FeedbackModerationReader и FeedbackWidgetReader. А IGetLatest с IFindById — тот же зоопарк буквы I, только на чтении.
Один клиент с четырьмя методами читается лучше, чем четыре файла с одним. Режем, когда клиенты реально разные, а не когда в интерфейсе стало больше одной строки.
Разумное решение: контракт на актора
God-интерфейс режем не «по методам», а по тому, кто чем пользуется. getForPeriod, delete и syncToCrm больше не живут вместе.
Сплит FeedbackReader / FeedbackWriter в LSP-части мы вводили, чтобы подстановка не врала. Для ISP подходит тот же код. Отчёт, выгрузка, список в админке и API аналитики только читают — им save() не нужен. FeedbackService::submit() только пишет — ему не нужен getForPeriod(). Класс в БД может уметь и то и другое. В конструктор клиенту кладём только его часть.
interface FeedbackReader
{
/** @return Feedback[] */
public function getForPeriod(DateRange $period): array;
}
interface FeedbackWriter
{
public function save(Feedback $feedback): void;
}
Список в админке и отправка отзыва выглядят так:
class FeedbackListController
{
public function __construct(
private FeedbackReader $reader
) {}
public function list(Request $request): Response
{
$period = $this->periodFromQuery($request);
$feedback = $this->reader->getForPeriod($period);
return new JsonResponse($this->toListPayload($feedback));
}
private function periodFromQuery(Request $request): DateRange
{
return new DateRange(
new \DateTimeImmutable((string) $request->query->get('from')),
new \DateTimeImmutable((string) $request->query->get('to')),
);
}
/** @param Feedback[] $items */
private function toListPayload(array $items): array
{
return array_map(
static fn(Feedback $f) => [
'id' => $f->id,
'rating' => $f->rating,
'text' => $f->text,
'channel' => $f->channel,
],
$items
);
}
}
class FeedbackService
{
public function __construct(
private FeedbackWriter $writer
) {}
public function submit(Feedback $feedback): void
{
$this->writer->save($feedback);
}
}
В LSP-части есть консольная команда, которая после инцидента дозаписывает пропущенные отзывы. Ей нужны и чтение, и запись — два аргумента:
class FeedbackBackfillCommand
{
public function __construct(
private FeedbackReader $reader,
private FeedbackWriter $writer
) {}
public function run(DateRange $period, Feedback $item): void
{
$this->reader->getForPeriod($period);
$this->writer->save($item);
}
}
Так можно подставить реплику на чтение и мастер на запись. Контейнер два аргумента подхватит сам. Лишних методов в команде нет.
Модератору нужно удалять спам — это всё ещё таблица отзывов, удаляет тот же репозиторий. Выгрузка в CRM ходит по HTTP, с ретраями и своими таймаутами. Это уже другой класс.
interface FeedbackDeleter
{
public function delete(int $id): void;
}
interface FeedbackCrmSync
{
public function syncToCrm(DateRange $period): void;
}
interface CrmClient
{
/**
* @param Feedback[] $items выборка за период целиком; чанкование опускаем
*/
public function upsertBatch(array $items): void;
}
FeedbackDeleter с одним методом — не IFormatable. Удаляет модератор, не тот, кто читает отчёты или список. Другие права, отдельный аудит. Размер интерфейса здесь следствие.
CrmClient — не кусок репозитория и не ISP. Им пользуется только FeedbackCrmSynchronizer. Интерфейс нужен, чтобы в тестах не ходить в живую CRM.
Persistence остаётся одним классом. CRM — нет:
class FeedbackRepository implements
FeedbackReader,
FeedbackWriter,
FeedbackDeleter
{
public function __construct(private \PDO $pdo) {}
/* getForPeriod() + save() + delete() */
}
class FeedbackCrmSynchronizer implements FeedbackCrmSync
{
public function __construct(
private FeedbackReader $reader,
private CrmClient $crm
) {}
public function syncToCrm(DateRange $period): void
{
$this->crm->upsertBatch($this->reader->getForPeriod($period));
}
}
Репозиторий по-прежнему один. Три implements — список видит чтение, форма — запись, модерация — ещё и удаление. В контейнере это тот же объект, нового слоя нет.
Клиенты видят только своё:
class ModerationController
{
public function __construct(
private FeedbackReader $reader,
private FeedbackDeleter $deleter
) {}
public function list(Request $request): Response
{
$items = $this->reader->getForPeriod(
$this->periodFromQuery($request)
);
return new JsonResponse($this->toListPayload($items));
}
public function delete(int $id): void
{
$this->deleter->delete($id);
}
}
class FeedbackCrmJob
{
public function __construct(
private FeedbackCrmSync $sync
) {}
public function run(DateRange $period): void
{
$this->sync->syncToCrm($period);
}
}
Job не ходит в репозиторий. Он зовёт syncToCrm(), а FeedbackCrmSynchronizer сам читает отзывы и отправляет их в CRM. CrmClient — про HTTP, не четвёртый интерфейс к той же таблице.
DI — persistence отдаёт три типа, CRM-синхронизатор — четвёртый:
$reader = $container->get(FeedbackReader::class); // FeedbackRepository
$writer = $container->get(FeedbackWriter::class); // тот же FeedbackRepository
$deleter = $container->get(FeedbackDeleter::class); // он же
$sync = $container->get(FeedbackCrmSync::class); // FeedbackCrmSynchronizer
$listController = new FeedbackListController($reader);
$feedbackService = new FeedbackService($writer);
$moderation = new ModerationController($reader, $deleter);
$crmJob = new FeedbackCrmJob($sync);
Нового слоя ради ISP не появилось.
Выгрузку из админки новым интерфейсом не закрываем. Берём тот же FeedbackReportFormatter, только отчёт отдаём в HTTP, а не через ReportSender — форматы мы уже вынесли в OCP-части. FeedbackReportService::send() шлёт письмо аналитикам. В админке нужно скачать файл в ответ, а не письмо.
Слева все ходят в один интерфейс. Справа модератор, аналитик и CRM-job зависят каждый от своего. Репозиторий один, в CRM — отдельный класс.
flowchart LR
subgraph bad["God-interface"]
M1[Модератор] --> G[FeedbackAdminOperations]
A1[Аналитик в админке] --> G
C1[CRM-job] --> G
G --> Impl1[(FeedbackRepository)]
end
subgraph good["Контракты по акторам"]
M2[Модератор] --> R[FeedbackReader]
M2 --> D[FeedbackDeleter]
A2[Аналитик в админке] -->|выгрузка через FeedbackReportFormatter| R
C2[CRM-job] --> S[FeedbackCrmSync]
R --> Impl2[(FeedbackRepository)]
D --> Impl2
S --> Sync[FeedbackCrmSynchronizer]
Sync --> R
Sync -->|CrmClient| Crm[(CRM)]
end
В тестах модерации мокают чтение и удаление. Проверять never()->method(‘syncToCrm’) незачем: этого метода в зависимостях уже нет.
Связка ISP и LSP: узкий интерфейс вместо наследования
В LSP-части антипаттерн выглядел так:
class ReadOnlyFeedbackRepository extends FeedbackRepository
{
public function save(Feedback $feedback): void
{
throw new \LogicException('Read-only repository');
}
}
С точки зрения LSP — нечестный подтип: базовый контракт обещает сохранение, подтип — нет.
А для ISP проблема в том, что save() виден коду, который только читает. Можно написать throw в save() — из интерфейса метод не исчезнет. PHPStan его видит, в рантайме вызов падает. Широкий интерфейс хотя бы не притворяется, что писать нельзя.
Правильный ход — не наследование с throw, а узкий контракт с самого начала:
FeedbackReader не отменяет LSP. CachingFeedbackReader из LSP-части по-прежнему должен вернуть те же отзывы за период — просто из кеша. HighRatedFeedbackReader — другая задача: только высокие оценки. Его подключают в cron под эту job, а не внутрь сервиса, который ждёт все отзывы. Узкий интерфейс лишь убирает save() из read-кода.
Если честно подставить подтип невозможно — интерфейс, скорее всего, слишком широкий для части клиентов. Разделите контракт до того, как кто-то напишет третий instanceof.
Узкие OCP-интерфейсы (FeedbackReportFormatter, ReportSender) уже проходят оба фильтра: клиент не тащит лишнего, реализации обязаны вести себя одинаково с точки зрения вызывающего кода. Дробить их дальше незачем — это антипаттерн 2.
Что сознательно не делаем
Даже разобравшись с ISP, мы не добавляем:
FeedbackRepositoryInterface, FeedbackRepositoryReaderInterface, FeedbackRepositoryWriterInterface, FeedbackRepositoryAdminInterface — четыре уровня на два метода
зоопарк IFormatable, ISendable, IGetForPeriod вместо нормальных FeedbackReportFormatter / ReportSender / FeedbackReader
самописный InterfaceSegregationValidator в CI — если границы модулей реально болят, хватит deptrac или PHPat, а не отдельного «ISP-чекера»
CompositeFeedbackOperations «на случай, если админка разрастётся»
Задача — узкие зависимости там, где клиенты реально разные, а не инфраструктура вокруг принципа. YAGNI никуда не делся.
Чеклист: когда дробить интерфейс
Перед тем как разрезать контракт, задайте пять вопросов:
Есть ли два и более клиента с разным набором вызовов? Один клиент вызывает все методы — дробление добавляет файлы, а не пользу. Один метод, переименованный в IGetForPeriod, — не сплит.
Видит ли клиент в интерфейсе методы, которых сценарий не вызывает? Если в тесте появился never()->method(‘delete’) или реализация пишет заглушку под широкий implements — сигнал, что клиент зависит от лишнего. Мок по узкому интерфейсу обычно сильнее, чем never() на широком.
Появляется ли throw new LogicException в «урезанной» реализации? Попытка починить подстановку наследованием. Нужен отдельный интерфейс.
Режем по актору и сценарию — или по методу? Модератор и CRM-job — разные акторы → разные контракты. getLatest у виджета и getForPeriod у отчётов — разные сценарии, если меняются порознь. Один FeedbackReportService, который всегда зовёт format() и send() вместе, не режем.
Контракт узкий — а реализация всё ещё God-класс? Пятый вопрос уже не про ISP, но проверьте заодно. delete() на PDO-репозитории — persistence. syncToCrm() туда же — уже другой актор и другая причина изменений: узкий интерфейс не отменяет SRP на стороне implements.
Это не математика. Это фильтр между «клиент не тащит лишнее» и «у нас 40 интерфейсов, зато SOLID».
Trade-offs
ISP vs DRY. Один FeedbackRepository закрывает FeedbackReader, FeedbackWriter и FeedbackDeleter. Это не копипаста и не «один метод ради ISP» — разные права и разные причины менять код. DRY про знания и поведение, не про то, сколько раз в проекте написано interface.
Цена ISP в PHP. Несколько интерфейсов — больше имён и несколько биндингов в контейнере. Имеет смысл, когда клиент в интерфейсе не видит save() / delete(): IDE и PHPStan/Psalm подскажут, если кто-то полезет в лишнее. Это не CQRS: модель на команды и запросы мы не делим. И не compile-time граница прав — PHP в рантайме всё равно вызовет метод у реального объекта. Узкий интерфейс защищает кодовую зависимость. Кто реально может писать в БД — отдельный разговор: ACL, роли, read replica.
ISP + LSP + OCP. OCP дал FeedbackReportFormatter для новых форматов и ReportSender для каналов. LSP уже сузил отчётный сервис и экспортный воркер до FeedbackReader и убрал нужду в ReadOnlyFeedbackRepository extends … ISP требует того же узкого типа для остальных клиентов — и отдельных контрактов там, где акторы разошлись дальше (FeedbackDeleter, FeedbackCrmSync). Три принципа не конкурируют; они сходятся на одном: контракт ровно той ширины, которую клиент использует.
ISP vs SRP. В SRP-части мы говорили про акторов — кто инициирует изменения. Разные акторы часто совпадают с разными клиентами интерфейса: cron отчётов не должен зависеть от операций модератора. ISP — практический инструмент SRP на уровне контрактов: не один класс «на всех», а зависимость ровно того, что нужно этому актору.
Вывод
Interface Segregation Principle легко свести к микроинтерфейсам и соревнованию по количеству файлов. Практичный подход другой.
ISP про зависимости клиента. Жирный интерфейс — когда в нём больше методов, чем нужно клиенту. Вопрос не «сколько методов в интерфейсе».
Группируйте по актору и сценарию, а не по методу. FeedbackDeleter существует, потому что модератор — другой клиент с другими правами, а не потому что «один метод = один файл». FeedbackCrmSync — тот же ход, но реализует его уже не репозиторий.
throw в read-only наследнике — симптом широкого контракта, а не решение. Узкий интерфейс дешевле runtime-запрета; LSP при этом никуда не девается.
Уже узкое не режем. FeedbackReportFormatter и ReportSender достаточно узки. IFormatable — космолёт из SRP-части, только с буквой I.
SOLID не отменяет KISS. Хорошая сегрегация — когда read-only клиент в конструкторе видит FeedbackReader, а не половину доменной модели «на всякий случай».
Делитесь в комментариях: где дробление интерфейсов реально спасло от багов — а где принесло только лишние implements?
В финале серии — Dependency Inversion Principle: кто владеет контрактом FeedbackReader — сценарий, который его вызывает, или адаптер, который его реализует? Куда класть сам файл относительно persistence — и почему «у нас всё через DI-контейнер» ещё не значит, что зависимости инвертированы.
А ещё подписывайтесь на канал нашего CTO Андрея Непряхина.
Что ещё почитать
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.