Дисклеймер. Статья для разработчиков уровня 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 никуда не делся.

Чеклист: когда дробить интерфейс

Перед тем как разрезать контракт, задайте пять вопросов:

  1. Есть ли два и более клиента с разным набором вызовов? Один клиент вызывает все методы — дробление добавляет файлы, а не пользу. Один метод, переименованный в IGetForPeriod, — не сплит.

  2. Видит ли клиент в интерфейсе методы, которых сценарий не вызывает? Если в тесте появился never()->method(‘delete’) или реализация пишет заглушку под широкий implements — сигнал, что клиент зависит от лишнего. Мок по узкому интерфейсу обычно сильнее, чем never() на широком.

  3. Появляется ли throw new LogicException в «урезанной» реализации? Попытка починить подстановку наследованием. Нужен отдельный интерфейс.

  4. Режем по актору и сценарию — или по методу? Модератор и CRM-job — разные акторы → разные контракты. getLatest у виджета и getForPeriod у отчётов — разные сценарии, если меняются порознь. Один FeedbackReportService, который всегда зовёт format() и send() вместе, не режем.

  5. Контракт узкий — а реализация всё ещё 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 легко свести к микроинтерфейсам и соревнованию по количеству файлов. Практичный подход другой.

  1. ISP про зависимости клиента. Жирный интерфейс — когда в нём больше методов, чем нужно клиенту. Вопрос не «сколько методов в интерфейсе».

  2. Группируйте по актору и сценарию, а не по методу. FeedbackDeleter существует, потому что модератор — другой клиент с другими правами, а не потому что «один метод = один файл». FeedbackCrmSync — тот же ход, но реализует его уже не репозиторий.

  3. throw в read-only наследнике — симптом широкого контракта, а не решение. Узкий интерфейс дешевле runtime-запрета; LSP при этом никуда не девается.

  4. Уже узкое не режем. FeedbackReportFormatter и ReportSender достаточно узки. IFormatable — космолёт из SRP-части, только с буквой I.

SOLID не отменяет KISS. Хорошая сегрегация — когда read-only клиент в конструкторе видит FeedbackReader, а не половину доменной модели «на всякий случай».

Делитесь в комментариях: где дробление интерфейсов реально спасло от багов — а где принесло только лишние implements?

В финале серии — Dependency Inversion Principle: кто владеет контрактом FeedbackReader — сценарий, который его вызывает, или адаптер, который его реализует? Куда класть сам файл относительно persistence — и почему «у нас всё через DI-контейнер» ещё не значит, что зависимости инвертированы.


А ещё подписывайтесь на канал нашего CTO Андрея Непряхина.

Что ещё почитать