SOLID в реальном мире: LSP без квадратов и прямоугольников

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

Всем доброго дня! На связи Валевич Артем — тимлид в компании AGIMA.

SOLID в реальном мире: LSP без квадратов и прямоугольников - 1

В первой части серии мы разобрали Single Responsibility Principle на кейсе системы отзывов. Во второйOpen-Closed Principle: появились FeedbackReportFormatter, ReportSender, две оси расширения — форматы отчётов и каналы доставки.

Код стал расширяемым. Интерфейсы на месте. DI подключает реализации. На code review всё красиво.

А в пятницу вечером cron шлёт ежедневный CSV в Slack — и хвост отчёта обрывается без единого предупреждения. Потому что «подменяемый» sender оказался не совсем подменяемым.

Сегодня разбирем — Liskov Substitution Principle (LSP, принцип подстановки Лисков). В учебниках его объясняют через квадрат и прямоугольник: у прямоугольника можно менять ширину и высоту независимо, у квадрата — нет, значит квадрат «ломает» ожидания клиента прямоугольника.

Пример классический. И бесполезный, если вы каждый день не проектируете геометрические фигуры.

На практике LSP ломается там, где OCP уже выиграл: в полиморфных интерфейсах, которые вы только что построили.

Главный вопрос: как применять LSP, когда OCP уже дал полиморфизм — и не сломать систему «подменяемыми» реализациями?

Что на самом деле означает LSP

Принцип сформулировала Барбара Лисков в докладе Data Abstraction and Hierarchy (OOPSLA, 1987). Позже, вместе с Jeannette Wing, его формализовали в терминах поведенческих подтиповA Behavioral Notion of Subtyping (1994): объекты подтипа должны быть заменяемы объектами базового типа без нарушения корректности программы.

Роберт Мартин включил LSP в SOLID и сформулировал проще:

Подтипы должны быть заменяемы для своих базовых типов.

Звучит как проверка на implements. На деле — нет.

LSP — про поведение и ожидания вызывающего кода, не про ключевое слово extends в объявлении класса. Если клиент работает с ReportSender, он вправе ожидать: любая реализация доставит отчёт по одним и тем же правилам. Не «доставит, если повезёт с форматом».

Связка с OCP прямая. Open-Closed обещает: новое поведение добавляем рядом, стабильный сценарий не трогаем. Но если новая реализация интерфейса ведёт себя иначе, чем остальные — OCP превращается в «открыто для расширения, закрыто для сюрпризов в проде».

Кейс: отзывы после OCP

Напомним контекст. Продукт с тремя каналами — сайт, мобильное приложение, PWA. Пользователь оставляет отзыв с оценкой 1–5, текстом и меткой канала. Отзывы лежат в PostgreSQL. Раз в неделю — XLSX на email, плюс ежедневный CSV для аналитиков. Часть отчётов уходит в Slack.

В OCP-части мы разобрали точки расширения — где добавлять новые форматы и каналы. Честность конкретных реализаций там не была темой: SlackReportSender на диаграмме — просто ещё одна стрелка к интерфейсу. Сегодня смотрим, что происходит, когда стрелка врёт.

К финальной точке OCP мы пришли к такому коду:

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 FeedbackRepository $repository
    ) {}

    public function send(
        DateRange $period,
        FeedbackReportFormatter $formatter,
        ReportSender $sender
    ): void {
        $feedback = $this->repository->getForPeriod($period);
        $report = $formatter->format($feedback);
        $sender->send($report);
    }
}

Период живёт снаружи — cron знает расписание. Комбинации собираются в job’ах:

// cron: ежедневный CSV → email
$reportService->send(
    DateRange::lastDay(),
    new CsvFeedbackReportFormatter(),
    new EmailReportSender()
);

// cron: еженедельный XLSX → Slack
$reportService->send(
    DateRange::lastWeek(),
    new XlsxFeedbackReportFormatter(),
    new SlackReportSender()
);

FeedbackReportService не знает конкретных классов. Он доверяет контракту: любой форматтер и любой sender. Именно здесь LSP либо работает, либо превращает пятничный деплой в квест.

Общие типы, на которых держится кейс, не изменились со времён OCP- и SRP-статей:

final class DateRange
{
    public function __construct(
        public readonly DateTimeImmutable $from,
        public readonly DateTimeImmutable $to,
    ) {}

    public static function lastDay(): self
    {
        $to = new DateTimeImmutable('today');
        return new self($to->modify('-1 day'), $to);
    }

    public static function lastWeek(): self
    {
        $to = new DateTimeImmutable('today');
        return new self($to->modify('-7 days'), $to);
    }
}

final class Attachment
{
    public function __construct(
        public readonly string $filename,
        public readonly string $content,
        public readonly string $mimeType,
    ) {}
}

final class Feedback
{
    public function __construct(
        public readonly int $id,
        public readonly int $rating,
        public readonly string $text,
        public readonly DateTimeImmutable $createdAt,
        public readonly string $channel, // site | app | pwa
    ) {}
}

А вот Report в этой части поменялся: рядом с format() появились isTextual() и body() — они понадобятся дальше, чтобы sender мог принимать решение по свойству отчёта, а не по перечислению форматов в коде:

final class Report
{
    private const TEXTUAL_FORMATS = ['csv'];

    public function __construct(
        private string $format, // csv | xlsx | pdf
        private string $body,
        private string $filename,
    ) {}

    public function format(): string
    {
        return $this->format;
    }

    /** csv читается как текст; xlsx/pdf — бинарные контейнеры */
    public function isTextual(): bool
    {
        return in_array($this->format, self::TEXTUAL_FORMATS, true);
    }

    public function body(): string
    {
        return $this->body;
    }

    public function mimeType(): string
    {
        return match ($this->format) {
            'csv' => 'text/csv',
            'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
            'pdf' => 'application/pdf',
            default => 'application/octet-stream',
        };
    }

    public function asAttachment(): Attachment
    {
        return new Attachment($this->filename, $this->body, $this->mimeType());
    }
}

Антипаттерн 1: «подменяемый» sender с ужесточёнными правилами

Разработчик пишет SlackReportSender. У Slack есть практические ограничения на размер текстового сообщения (уточняйте актуальное значение в документации платформы — оно меняется). Разумная мысль. Но реализация получается такой:

class SlackReportSender implements ReportSender
{
    private const MAX_MESSAGE_LENGTH = 4000;

    public function __construct(
        private SlackClient $slack,
        private string $channelId,
    ) {}

    public function send(Report $report): void
    {
        if (!$report->isTextual()) {
            throw new InvalidArgumentException('Slack sender поддерживает только текстовые отчёты');
        }

        $fullText = $report->body();
        $text = mb_strlen($fullText) > self::MAX_MESSAGE_LENGTH
            ? mb_substr($fullText, 0, self::MAX_MESSAGE_LENGTH) // молча режем хвост
            : $fullText;

        $this->slack->postMessage($this->channelId, $text);
    }
}

Для сравнения — EmailReportSender, с которым cron уже работает без сюрпризов:

class EmailReportSender implements ReportSender
{
    public function __construct(
        private Mailer $mailer,
        private string $recipient,
    ) {}

    public function send(Report $report): void
    {
        $this->mailer->send(
            to: $this->recipient,
            subject: 'Отчёт по отзывам',
            attachment: $report->asAttachment(),
        );
    }
}

Формально SlackReportSender реализует ReportSender. На диаграмме — ещё одна стрелочка к интерфейсу.

Практически — нарушение LSP, причём сразу двойное. Важно: нарушение — относительно ожидания клиента и контракта ReportSender («доставить любой Report целиком»), а не относительно «среднего поведения» соседних реализаций. Сравнение с EmailReportSender ниже — иллюстрация того, что клиент уже привык получать; сам по себе факт «Email ведёт себя иначе» не задаёт контракт для Slack.

ужесточает предусловие на вход: отклоняет XLSX — хотя клиент ReportSender вправе передать любой Report (и EmailReportSender это ожидание выполняет);

ослабляет постусловие на том, что принял: молча обрезает длинный CSV — хотя контракт обещает полную доставку (и email отправил бы отчёт целиком).

Это и есть пятничный инцидент из интро: ежедневный CSV проходит валидацию и тихо теряет хвост, если он длиннее лимита сообщения — сколько именно потеряется, зависит от объёма конкретного отчёта, не обязательно «половина». А еженедельный XLSX в Slack в этой версии кода падает с исключением сразу — то есть отчёт теряется целиком, а не частично, что для получателя ничем не лучше.

Через неделю в вызывающем коде появляется:

$formatter = new XlsxFeedbackReportFormatter();
$sender = $channel === 'slack'
    ? new SlackReportSender($slack, $channelId)
    : new EmailReportSender($mailer, $recipient);

if ($sender instanceof SlackReportSender && $formatter instanceof XlsxFeedbackReportFormatter) {
    $sender = new EmailReportSender($mailer, $recipient); // обходной манёвр
}

$reportService->send(DateRange::lastWeek(), $formatter, $sender);

instanceof в бизнес-логике — классический запах: подтип нельзя честно подставить вместо базового типа. Интерфейс обещает одно, реализация — другое.

Антипаттерн 2: наследование репозитория «для удобства»

Вернёмся к хранению. В SRP-части FeedbackRepository отвечает за запись и чтение:

class FeedbackRepository
{
    public function __construct(private PDO $pdo) {}

    public function save(Feedback $feedback): void
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO feedback (rating, text, channel, created_at)
             VALUES (:rating, :text, :channel, :created_at)'
        );
        $stmt->execute([
            'rating' => $feedback->rating,
            'text' => $feedback->text,
            'channel' => $feedback->channel,
            'created_at' => $feedback->createdAt->format('Y-m-d H:i:s'),
        ]);
    }

    public function getForPeriod(DateRange $period): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, rating, text, channel, created_at
             FROM feedback
             WHERE created_at BETWEEN :from AND :to
             ORDER BY created_at'
        );
        $stmt->execute([
            'from' => $period->from->format('Y-m-d H:i:s'),
            'to' => $period->to->format('Y-m-d H:i:s'),
        ]);

        return array_map(
            fn(array $row) => new Feedback(
                id: (int) $row['id'],
                rating: (int) $row['rating'],
                text: $row['text'],
                createdAt: new DateTimeImmutable($row['created_at']),
                channel: $row['channel'],
            ),
            $stmt->fetchAll(PDO::FETCH_ASSOC)
        );
    }
}

Здесь LSP и Interface Segregation смыкаются заранее: то, что произойдёт дальше — симптом нарушения подстановки, но его причина — слишком широкий интерфейс. Полный разбор сегрегации будет в следующей статье, посвящённой ISP, здесь фиксируем только LSP-часть проблемы.

Появляется задача: нужен воркер, который экспортирует отзывы во внешнюю аналитическую систему, — ему нужно только чтение. Кто-то решает «унаследуемся — меньше кода»:

class ReadOnlyFeedbackRepository extends FeedbackRepository
{
    public function save(Feedback $feedback): void
    {
        throw new LogicException('Read-only repository');
    }
}

Классика учебников. И классика продакшн-багов.

FeedbackReportService принимает FeedbackRepository — хотя для отчётного сценария write не нужен. Зависимость от полного репозитория сама по себе расширяет контракт: сервису теоретически доступен save(), который он никогда не вызовет. Тесты подставляют мок. Для экспортного воркера — ReadOnlyFeedbackRepository. Типы сходятся.

Пока полгода спустя правило autowiring в DI-контейнере не переиспользует тот же биндинг там, где его не ждали: например, в консольной команде бэкфилла, которая должна дозаписать пропущенные отзывы из очереди после инцидента. Команда получает ReadOnlyFeedbackRepository вместо полного репозитория — и падает в рантайме на save(). Потому что подтип не выполняет постусловие** ****save()**: базовый тип обещает успешное сохранение, подтип — нет.

Другой вариант — CachingFeedbackRepository без инвалидации:

class CachingFeedbackRepository extends FeedbackRepository
{
    /** @var array<string, Feedback[]> */
    private array $cache = [];

    public function getForPeriod(DateRange $period): array
    {
        $key = $period->from->format(DATE_ATOM) . ':' . $period->to->format(DATE_ATOM);

        return $this->cache[$key] ??= parent::getForPeriod($period);
    }

    public function save(Feedback $feedback): void
    {
        parent::save($feedback);
        // кеш не сброшен — getForPeriod() вернёт снимок до save()
    }
}

save() пишет в БД, но кеш по ключу периода не сбрасывается. Еженедельный отчёт, запущенный повторно в том же процессе, уходит с устаревшими данными. Подтип ослабил постусловие** ****getForPeriod()**: «вернёт актуальные отзывы за период».

Квадрат и прямоугольник — абстракция. Отчёт с неполными отзывами — уже разговор с продактом в понедельник утром.

Антипаттерн 3: форматтер с другой семантикой

Третье место, где LSP ломается незаметно — FeedbackReportFormatter. Период теперь снаружи, и сервис честно запрашивает у репозитория все отзывы за DateRange. Но форматтер может тихо изменить смысл данных.

Тихая фильтрация, которой нет в контракте:

class CsvFeedbackReportFormatter implements FeedbackReportFormatter
{
    public function format(array $feedback): Report
    {
        // бизнес не просил, но «аналитикам нужны только хорошие»
        $filtered = array_filter($feedback, fn(Feedback $f) => $f->rating >= 4);
        $rows = array_map(
            fn(Feedback $f) => [$f->id, $f->rating, $f->text, $f->channel],
            $filtered
        );
        $body = (new CsvWriter())->write(['id', 'rating', 'text', 'channel'], $rows);

        return new Report('csv', $body, 'feedback-daily.csv');
    }
}

FeedbackReportService передал в format() все отзывы за сутки — репозиторий отработал по $period. А CsvFeedbackReportFormatter тихо отбрасывает часть. XlsxFeedbackReportFormatter работает со всем массивом. Подстановка формально возможна, семантика — разная.

Важно: проблема не в самом факте фильтрации, а в том, что она спрятана внутри класса, реализующего общий интерфейс. Переименование класса в HighRatedCsvFeedbackReportFormatter эту проблему не решает — FeedbackReportService по-прежнему принимает любой FeedbackReportFormatter, и подстановка одной реализации вместо другой по-прежнему меняет содержимое отчёта. Контракт FeedbackReportFormatter должен звучать однозначно: на выходе — отчёт из всех переданных записей, без исключений. Если бизнесу нужна фильтрация — это отдельная ось изменений, и место для неё не форматтер, а источник данных. Разберём это в разделе с решением.

Мутация объектов внутри входного массива:

В PHP массив передаётся по значению, но объекты внутри — по ссылке. В нашем кейсе Feedback — readonly: изменить $feedback->text нельзя, и целый класс таких багов снимается на уровне типа. Но в легаси-слое отзывы часто лежат в mutable-DTO — и там форматтер может тихо переписать данные:

class FeedbackRecord // легаси-модель, не readonly
{
    public function __construct(
        public int $id,
        public int $rating,
        public string $text,
        public DateTimeImmutable $createdAt,
        public string $channel,
    ) {}
}

class LegacyXlsxFeedbackReportFormatter implements FeedbackReportFormatter
{
    /** @param FeedbackRecord[] $feedback */
    public function format(array $feedback): Report
    {
        foreach ($feedback as $record) {
            // «нормализуем» текст прямо в объекте
            $record->text = trim(strip_tags($record->text));
        }
        $body = (new XlsxWriter())->fromRecords($feedback);

        return new Report('xlsx', $body, 'feedback-weekly.xlsx');
    }
}

Здесь на самом деле два нарушения LSP, и оба стоит уметь различать.

Первое — мутация: сервис передал массив в format() и может передать тот же массив дальше — в логирование, метрики, другой обработчик. Тексты уже изменены. Другие форматтеры объекты не трогают. Контракт «на входе — отзывы в исходном виде» нарушен неявно.

Второе — менее очевидное: LegacyXlsxFeedbackReportFormatter объявляет @param FeedbackRecord[], хотя интерфейс требует @param Feedback[]. Это сужение типа входного параметра — нарушение LSP на уровне сигнатуры (контравариантность параметров нарушена в обратную сторону). Статический анализатор (PHPStan, Psalm) на приличном уровне строгости подсветит это раньше, чем заметит человек на код-ревью. Честный вариант — не подмешивать легаси-тип в общий интерфейс, а развести контракты: отдельный интерфейс для легаси-конвейера, либо явное приведение FeedbackRecord к Feedback на границе перед вызовом форматтера:

interface LegacyFeedbackReportFormatter
{
    /** @param FeedbackRecord[] $feedback */
    public function format(array $feedback): Report;
}

Так легаси-форматтер больше не притворяется реализацией FeedbackReportFormatter с суженным @param — у него свой контракт и свой конвейер.

Честный вариант работы с мутацией — не мутировать вход, а работать с копией:

$normalized = array_map(
    fn(FeedbackRecord $r) => new FeedbackRecord(
        id: $r->id,
        rating: $r->rating,
        text: trim(strip_tags($r->text)),
        createdAt: $r->createdAt,
        channel: $r->channel,
    ),
    $feedback
);
$body = (new XlsxWriter())->fromRecords($normalized);

return new Report('xlsx', $body, 'feedback-weekly.xlsx');

Заглушка «на будущее»:

В OCP-части PdfFeedbackReportFormatter показан как гипотетическое расширение — «если бизнес попросит». На бумаге это здоровый OCP. LSP ломается, когда такую заглушку подключают в DI и cron до готовности:

class PdfFeedbackReportFormatter implements FeedbackReportFormatter
{
    public function format(array $feedback): Report
    {
        return new Report('pdf', '', 'feedback-monthly.pdf'); // PDF сделаем в следующем спринте
    }
}

Класс уже в контейнере. Unit-тесты с моком — зелёные. Cron в проде — пустые отчёты. Подтип существует, типовой контракт формально не нарушен, поведенческий — сломан.

Где проходит граница: контракт подстановки

LSP в академическом виде говорит про предусловия, постусловия и инварианты. В production достаточно трёх практических вопросов:

Можно ли передать реализацию в FeedbackReportService без ветвления по типу?

Ведут ли все реализации себя одинаково с точки зрения клиента — что принимают, что гарантируют, когда бросают исключение?

Можно ли подставить другую реализацию того же интерфейса — другой ReportSender вместо email, другой форматтер вместо XLSX — без правок FeedbackReportService?

Третий вопрос — не про «любая пара formatter × sender в cron». Композиция на границе (cron, конфиг) может и должна отсеивать бизнес-невалидные пары. LSP про подтипы внутри одного интерфейса, а не про то, что daily CSV обязан улетать в Slack.

Если на вопросы 1–3 ответ «нет» — проблема не в «неправильном SOLID», а в дыре в контракте.

Исключение по аргументу и исключение по среде — не одно и то же

Разумный вопрос: SlackReportSender бросает исключение на нетекстовый формат — почему это нарушение LSP, а EmailReportSender, падающий при недоступном SMTP, — нет? В обоих случаях throw.

Разница — в том, от чего зависит исключение:

Исключение, зависящее от аргумента («этот формат я не приму») — ужесточённое предусловие. Один и тот же вызов с одним и тем же отчётом ведёт себя по-разному в зависимости от того, какая реализация подставлена. Это нарушение.

Исключение, зависящее от внешней среды («SMTP недоступен», «Slack API вернул 500») — часть контракта, одинаковая для всех реализаций: любой sender может не доставить отчёт по вине инфраструктуры. Это норма, и клиент обязан быть готов к такому исходу независимо от конкретной реализации. Но «часть контракта» означает и согласованный тип ошибки: если один sender бросает TransportException, другой — InvalidArgumentException, а клиент рассчитывает обработать единый тип — подстановка уже проблемна. Ошибки среды допустимы, если их тип, момент возникновения и семантика согласованы общим контрактом — например, @throws ReportDeliveryException на ReportSender::send().

Проверка простая: если два вызова с одинаковым Report, но разными экземплярами одного интерфейса дают разный результат из-за содержимого** **Report — это LSP. Если разный результат зависит от состояния внешнего сервиса — это эксплуатационный риск, не нарушение принципа (при условии, что тип ошибки тоже согласован).

Чеклист перед merge

Перед тем как добавить новую реализацию интерфейса, задайте пять вопросов:

Вызывающий код знает конкретный подтип? Если да — LSP уже под вопросом. Интерфейс декоративный.

Подтип ужесточает вход? «Принимаю только текстовые форматы», «только для admin» — это другой контракт. Нужен отдельный интерфейс или явный параметр, не тихий throw внутри.

Подтип ослабляет гарантии? Неполные данные, молчаливая обрезка, no-op вместо действия — composition или отдельный тип, не extends.

Тесты с моком зелёные, интеграция с другой реализацией — нет? Проверьте контракт на всех реализациях, не на одной.

В бизнес-коде появился** instanceof?** Пересмотрите иерархию. Скорее всего, интерфейс слишком широкий или реализация нечестная.

Это не математика. Это фильтр между «у нас полиморфизм» и «у нас полиморфизм, но только если знать, какой класс подставить».

Разумное решение: честная подстановка

Sender: решение по свойству отчёта, не по списку форматов

SlackReportSender должен доставлять весь отчёт — как EmailReportSender. Ограничения платформы решаются внутри реализации, а не за счёт молчаливой потери данных:

class SlackReportSender implements ReportSender
{
    private const MAX_MESSAGE_LENGTH = 4000;

    public function __construct(
        private SlackClient $slack,
        private string $channelId,
    ) {}

    public function send(Report $report): void
    {
        if (!$report->isTextual() || mb_strlen($report->body()) > self::MAX_MESSAGE_LENGTH) {
            $attachment = $report->asAttachment();
            $this->slack->uploadFile(
                channel: $this->channelId,
                filename: $attachment->filename,
                content: $attachment->content,
                mimeType: $attachment->mimeType,
                comment: 'Отчёт по отзывам',
            );
            return;
        }

        $this->slack->postMessage($this->channelId, $report->body());
    }
}

Клиент не знает про лимиты Slack и про то, какие форматы бинарные. Контракт ReportSender сохранён: send() либо доставляет полный отчёт — сообщением или файлом, — либо бросает ReportDeliveryException по вине среды, как email при недоступном SMTP. «Полный» здесь означает наблюдаемую для клиента целостность: содержимое, имя файла и MIME type — а не одинаковый внутренний механизм доставки. Сообщение вместо вложения допустимо, если содержимое не потеряно; канал, частичный успех и ретраи — уже эксплуатационные детали за скоупом этого контракта. Никакой ветки «режем и отправляем часть». Решение принимается по Report::isTextual() и длине содержимого — а не по перечислению конкретных форматов, поэтому новый формат (PDF, если бизнес его попросит) не потребует правок sender’а: это тот же OCP, за который вы уже заплатили в прошлой части.

Repository: composition вместо наследования

Read-only доступ — не подтип writable-репозитория. Отдельный контракт:

interface FeedbackReader
{
    /**
     * @return Feedback[] Отзывы за период.
     *                     Кеширование и иные декораторы поверх reader'а
     *                     не меняют семантику выборки — только способ её
     *                     получить. Фильтрация — отдельный семантический
     *                     тип (см. HighRatedFeedbackReader ниже), а не
     *                     «любой состав, который решит реализация».
     */
    public function getForPeriod(DateRange $period): array;
}

interface FeedbackWriter
{
    public function save(Feedback $feedback): void;
}

FeedbackRepository реализует оба — для сценариев записи. Экспортный воркер и консольная команда бэкфилла получают именно тот интерфейс, который им нужен: воркер — FeedbackReader, команда бэкфилла — оба. Перепутать их на уровне DI-типов теперь невозможно физически, а не по соглашению. Отчётный сервис сужаем:

class FeedbackReportService
{
    public function __construct(
        private FeedbackReader $repository
    ) {}

    public function send(
        DateRange $period,
        FeedbackReportFormatter $formatter,
        ReportSender $sender
    ): void {
        $feedback = $this->repository->getForPeriod($period);
        $report = $formatter->format($feedback);
        $sender->send($report);
    }
}

Никакого throw в переопределённом save(). Нечестного наследования ради экономии строки implements. Лишний интерфейс и wiring в DI — цена за гарантию на уровне типов вместо runtime LogicException. Здесь — минимальный сплит ради честной подстановки; полноценный разбор сегрегации — в следующей статье (см. trade-off ниже).

Кеширование теперь тоже решается composition, а не наследованием от конкретного класса:

final class CachingFeedbackReader implements FeedbackReader
{
    /** @var array<string, Feedback[]> */
    private array $cache = [];

    public function __construct(private FeedbackReader $inner) {}

    public function getForPeriod(DateRange $period): array
    {
        $key = $period->from->format(DATE_ATOM) . ':' . $period->to->format(DATE_ATOM);

        return $this->cache[$key] ??= $this->inner->getForPeriod($period);
    }
}

Раз запись теперь отдельный интерфейс (FeedbackWriter), в отчётном сценарии кешируется только чтение — путь записи через этот декоратор вообще не проходит, и классический баг «save() не сбросил кеш» здесь структурно не возникает.

Оговорка честности здесь жёстче, чем «работает только в рамках одного процесса». Декоратор защищает только от staleness, вызванного записью через себя же. Даже внутри одного long-running процесса (воркер, демон) кеш устареет, если запись пройдёт мимо этого экземпляра — через другой FeedbackWriter, соседний сервис или прямой SQL-запрос, который не в курсе, что рядом кто-то кеширует. Если процесс живёт дольше одного запроса и держит такой кеш, TTL или явная инвалидация по событию save() нужны независимо от того, один воркер или несколько. При нескольких процессах это верно ещё сильнее: кеш каждого живёт независимо, и распределённая инвалидация — уже вопрос архитектуры кеша, не LSP.

Formatter: неизменяемый вход, вся выборка, честная семантика

Контракт FeedbackReportFormatter фиксируем явно — в PHPDoc и в тестах:

format() не мутирует объекты во входном массиве (для readonly Feedback это гарантия типа; для легаси — явный контракт);

format() строит отчёт из всех переданных отзывов — без тихой фильтрации, без исключений;

сигнатура format(array $feedback) не сужается в реализациях — легаси-конвейер получает собственный интерфейс, а не переопределённый тип параметра.

Фильтрация по рейтингу — не задача форматтера. Она либо в самом запросе к БД, либо в отдельном декораторе над FeedbackReader, тем же способом, каким мы только что закешировали чтение:

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
        ));
    }
}

Здесь стоит быть честным: перенос фильтрации с форматтера на reader не отменяет вопрос семантики, а лишь переносит его в правильное место. Базовый контракт FeedbackReader::getForPeriod() обещает отзывы за период — не «какой-то набор на усмотрение реализации». HighRatedFeedbackReader формально реализует тот же интерфейс, но это отдельный семантический тип-декоратор: он сужает выборку по рейтингу и поэтому не является честной подменой «полного» reader’а внутри кода, который ждёт всю выборку. LSP здесь не нарушен именно потому, что подмена происходит не там, где клиент уже написан под полную выборку, — а в composition root, где cron явно собирает другую сборку зависимостей под задачу «только высокие оценки». Если бы FeedbackReportService сам решал, каким reader’ом пользоваться, в зависимости от условий — это уже был бы instanceof-запах из чеклиста выше, и подстановка HighRatedFeedbackReader вместо полного reader’а внутри уже написанного сервиса действительно ломала бы ожидание «все отзывы за период». А пока выбор reader’а — конфигурация job’а, а не runtime-ветвление, разные job’ы получают разные семантические сборки осознанно: полный reader — для полной выборки, HighRatedFeedbackReader — для отфильтрованной. Ослаблять базовый PHPDoc до «состав определяется реализацией» ради того, чтобы формально «не нарушить» LSP, не стоит: такой контракт почти невозможно нарушить, но он слишком слаб для бизнес-сервиса.

Cron, которому нужны только высокие оценки, собирает сервис с нужным reader’ом — форматтер об этом ничего не знает и не меняется:

$reportService = new FeedbackReportService(
    new HighRatedFeedbackReader($repository)
);

$reportService->send(
    DateRange::lastDay(),
    new CsvFeedbackReportFormatter(),
    new SlackReportSender($slack, $channelId)
);

Так фильтрация становится осью изменений на уровне источника данных — предсказуемо для OCP, и не ломает контракт FeedbackReportFormatter для LSP. CsvFeedbackReportFormatter из антипаттерна выше просто перестаёт существовать в такой форме: любой форматтер честно обрабатывает всё, что ему передали.

Контрактные тесты — минимальная цена OCP

Для ReportSender дата-провайдер плохо подходит: чтобы проверить, что sender доставил содержимое, тесту нужен доступ к состоянию fake-транспорта после send() — а PHPUnit 10+ требует, чтобы дата-провайдеры были static и не имели доступа к $this. Правильный инструмент здесь — абстрактный контрактный тест-кейс, один раз описывающий сценарии для всех реализаций. Важно, чтобы в наборе был не только длинный текстовый отчёт, но и бинарный — именно он ловит сценарий из интро (XLSX, отклонённый или обрезанный Slack-реализацией):

abstract class ReportSenderContractTest extends TestCase
{
    abstract protected function createSender(): ReportSender;
    abstract protected function assertDeliveredIntact(Report $report): void;

    public function testDeliversShortTextualReport(): void
    {
        $report = new Report('csv', "id,ratingn1,5n", 'daily.csv');

        $this->createSender()->send($report);

        $this->assertDeliveredIntact($report);
    }

    public function testDeliversLongTextualReport(): void
    {
        $report = new Report('csv', str_repeat('A', 5000), 'weekly.csv');

        $this->createSender()->send($report);

        $this->assertDeliveredIntact($report);
    }

    public function testDeliversBinaryReport(): void
    {
        $report = new Report('xlsx', "PKx03x04binary-content", 'weekly.xlsx');

        $this->createSender()->send($report);

        $this->assertDeliveredIntact($report);
    }
}

final class SlackReportSenderTest extends ReportSenderContractTest
{
    private FakeSlackClient $slack;

    protected function createSender(): ReportSender
    {
        $this->slack = new FakeSlackClient();
        return new SlackReportSender($this->slack, 'C123');
    }

    protected function assertDeliveredIntact(Report $report): void
    {
        // FakeSlackClient запоминает доставку независимо от того,
        // ушла она через postMessage() или uploadFile()
        self::assertSame($report->body(), $this->slack->deliveredContent());

        // Имя файла и MIME — часть полной доставки для вложения;
        // короткое текстовое сообщение их не несёт, и это допустимо
        // по контракту (см. выше: сообщение вместо файла ок, если
        // содержимое целое)
        if ($this->slack->deliveredAsFile()) {
            self::assertSame($report->asAttachment()->filename, $this->slack->deliveredFilename());
            self::assertSame($report->mimeType(), $this->slack->deliveredMimeType());
        }
    }
}

final class EmailReportSenderTest extends ReportSenderContractTest
{
    private FakeMailer $mailer;

    protected function createSender(): ReportSender
    {
        $this->mailer = new FakeMailer();
        return new EmailReportSender($this->mailer, 'team@example.com');
    }

    protected function assertDeliveredIntact(Report $report): void
    {
        $attachment = $this->mailer->lastAttachment();

        self::assertSame($report->body(), $attachment->content);
        self::assertSame($report->asAttachment()->filename, $attachment->filename);
        self::assertSame($report->mimeType(), $attachment->mimeType);
    }
}

Именно testDeliversBinaryReport в старой версии SlackReportSender из антипаттерна 1 упал бы с исключением, а в версии до неё — тихо испортил бы бинарный XLSX обрезкой по mb_substr. Оба варианта не проходят один и тот же тест на всех реализациях — контракт ловится автоматически, а не когда кто-то заметит пустой отчёт в проде.

Новая реализация ReportSender без своего наследника ReportSenderContractTest — красный флаг на code review: контракт не проверен.

Для форматтеров дата-провайдер работает нормально — здесь не нужен доступ к состоянию после вызова, достаточно проверить содержимое результата:

/** @dataProvider formatterProvider */
public function testFormatterIncludesAllFeedback(FeedbackReportFormatter $formatter): void
{
    $feedback = [
        new Feedback(1, 1, 'плохо', new DateTimeImmutable(), 'site'),
        new Feedback(2, 5, 'отлично', new DateTimeImmutable(), 'app'),
    ];

    $report = $formatter->format($feedback);

    self::assertStringContainsString('плохо', $report->body());
    self::assertStringContainsString('отлично', $report->body());
}

Именно этот тест ловит тихую фильтрацию из антипаттерна 3 — и не пропустит в formatterProvider ничего, что фильтрует «для удобства аналитиков».

Для легаси-форматтеров на mutable-DTO — отдельный провайдер, не смешиваем с основным:

/** @dataProvider legacyFormatterProvider */
public function testLegacyFormatterDoesNotMutateInputObjects(FeedbackReportFormatter $formatter): void
{
    $feedback = [
        new FeedbackRecord(1, 3, '  ok  ', new DateTimeImmutable(), 'app'),
        new FeedbackRecord(2, 5, 'great', new DateTimeImmutable('-1 hour'), 'site'),
    ];
    $textBefore = array_map(fn(FeedbackRecord $r) => $r->text, $feedback);

    $formatter->format($feedback);

    self::assertSame($textBefore, array_map(fn(FeedbackRecord $r) => $r->text, $feedback));
}

PdfFeedbackReportFormatter-заглушка в том же formatterProvider упадёт на testFormatterIncludesAllFeedback — и не попадёт в cron, пока реализация не готова.

Тесты гоняем через fake transport (in-memory mail, fake Slack API), не через реальные каналы. Тот же подход — для всех реализаций интерфейса. Дешевле, чем чинить прод в пятницу.

Что сознательно не делаем

Даже разобравшись с LSP, мы не добавляем:

ContractValidatorInterface и фреймворк проверки подтипов

AbstractReportSender с дефолтными throw new UnsupportedOperationException

LspComplianceChecker в CI

иерархию из пяти уровней «на случай, если появится ещё один канал»

Задача — честные реализации существующих интерфейсов, а не инфраструктура вокруг принципа. YAGNI никуда не делся.

Trade-off: семантика, ISP и тесты

Производительность. LSP не требует, чтобы все реализации работали одинаково быстро. Требует одинаковой наблюдаемой семантики: клиент не должен менять логику в зависимости от подтипа.

ISP ↔ LSP. Если честно подставить подтип невозможно — интерфейс, скорее всего, слишком широкий. ReadOnlyFeedbackRepository extends FeedbackRepository — одновременно нарушение LSP и предвкус Interface Segregation (следующая статья серии): разделите read и write, пока кто-то не написал третий instanceof.

Контрактные тесты vs дублирование. Абстрактный тест-кейс на интерфейс — не DRY ради DRY, а страховка полиморфизма, за который вы заплатили в OCP-части.

Адаптация внутри sender. Email «просто шлёт вложение». Slack — сообщение или загрузка файла в зависимости от типа контента: разная стоимость и операционные риски. LSP требует одинаковой наблюдаемой семантики для клиента, не одинаковой реализации внутри.

Вариантность типов в PHP. Возвращаемый тип метода в наследнике можно сужать (ковариантность, PHP 7.4+) — это безопасно и LSP не нарушает. А вот тип параметра сужать нельзя — это ужесточает предусловие. PHP частично защищает от этого на уровне сигнатур классов, но не защищает PHPDoc-аннотации вроде @param FeedbackRecord[] из антипаттерна 3 — их проверяет только статический анализатор, а не движок.

final** **как профилактика. Класс, который не планировался как база для наследования, стоит помечать final. Половина нарушений LSP в этой статье («унаследуемся для удобства») физически не случится, если унаследоваться просто нельзя.

Вывод

Liskov Substitution Principle — не про квадраты на доске и не про запрет на extends.

Практичный подход:

Полиморфизм из OCP работает только с честными реализациями. Интерфейс без LSP — декорация.

instanceof** **в бизнес-коде — симптом. Лечится контрактом, не ещё одной веткой.

Наследование ради переиспользования имени — частый источник нарушений. Composition и узкие интерфейсы обычно дешевле.

Контракт фиксируйте тестами, не только PHPDoc. Особенно если реализаций больше одной — и особенно если для проверки нужен доступ к состоянию fake-объекта: тогда дата-провайдер не подойдёт, нужен абстрактный тест-кейс.

Ужесточать вход или ослаблять выход в подтипе — повод для отдельного типа или переноса вариативности на уровень источника данных, а не для тихого if внутри implements.

SOLID не отменяет здравый смысл. Хорошая подстановка — когда cron меняет sender в конфиге, а не в коде с тремя if.

Делитесь в комментариях: где подмена реализации сломала прод — и как вы это починили?

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

SOLID в реальном мире. OCP без «давайте сразу сделаем расширяемо»

SOLID в реальном мире: SRP без архитектурных космолетов

Базовый набор тимлида: от каких убеждений стоит отказаться, чтобы команда тебя не возненавидела

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

Автор: avalevich

Источник

Оставить комментарий