Daily MaverickEx-Google DeepMind researcher adds to warnings that AI could ‘kill all humans’וואלהצה"ל חיסל את מפקד הפלוגה במטה המבצעים בחמאס, יחד מחבל נוסףESPNCeltics offseason recap and early-season preview: Tatum returns as the catalystRTP Desporto12h30 Benfica a 100% para Amorim, Ramos e Diego MoreiraPunchKenya to host 2029 World Athletics Championships in African firstThe Jerusalem PostPolice investigating threats against prominent Munich Holocaust survivor and AfD opponentInquirerWATCH: Ombudsman lawyer takes the witness standColliderMike Flanagan's ‘Carrie’ Could Officially Go Far Beyond Stephen King’s Original Story [Exclusive]SCMP ChinaChina mulls building nuclear-powered tank with 450km-range railgun in 2 decadesSouth China Morning PostChina bets on chips and AI in new 5-year road map to challenge US tech dominanceBusiness AMNieuwe nucleaire raket Sentinel bereikt belangrijke mijlpaal en is klaar voor testvlucht in 2027VarietyKurosawa Kiyoshi’s Cannes Title ‘The Samurai and the Prisoner’ Sells Wide for Charades (EXCLUSIVE)
The Daily Newsstand · Free, Always
Tuesday, September 15, 2026

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

Translate

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

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

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.