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

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

Всем доброго дня! На связи Валевич Артем — тимлид в компании AGIMA. В первой части серии мы разобрали Single Responsibility Principle на кейсе системы отзывов и пришли к простому выводу:

SOLID нужен не для того, чтобы плодить классы ради классов.

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

Сегодня — Open‑Closed Principle (OCP, принцип открытости‑закрытости). И снова тот же production‑кейс: отзывы, XLSX‑отчёты, почта. Потому что на практике OCP ломают не в учебнике с фигурами, а в обычном сервисе — ровно в тот момент, когда кто‑то из команды говорит: «Давайте сразу сделаем расширяемо».

Главный вопрос: как применять OCP в реальном коде и не преврить каждый класс в фабрику стратегий?

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

Принцип впервые сформулировал Bertrand Meyer в книге Object‑Oriented Software Construction (1988): “Software entities should be open for extension, but closed for modification.” Перевод на русский привычен: «Программные сущности должны быть открыты для расширения, но закрыты для изменения.»

Роберт Martin позже включил OCP в свой набор SOLID и подчёркивал: это один из самых важных принципов объектного проектирования — но только если понимать его правильно. У многих после определения складывается логичная, но опасная интерпретация: значит, класс нельзя трогать. Нужен интерфейс, реализация и DI‑контейнер — иначе нарушим SOLID.

Нет.

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

Ключевые слова:

  • стабильное поведение;

  • понятная точка расширения;

  • новые варианты, которые уже появились или очевидно появятся.

Не ключевые:

  • «вдруг когда‑нибудь»

  • «я так видел в докладе»

  • «на всякий случай»

Напомним контекст. Продукт с тремя каналами — сайт, мобильное приложение, PWA. Пользователь оставляет отзыв, отзывы лежат в БД, раз в неделю формируется XLSX и уходит на email.

Требования на старте:

1. собрать отзыв;

2. сохранить отзыв;

3. сформировать XLSX‑отчёт;

4. отправить его на почту.

В SRP‑части мы уже разделили ответственности: сбор отзывов, хранение, отчёт — разные зоны. Теперь смотрим, как эволюционирует именно отчётный сценарий, когда в игру вступает OCP. Антипаттерн: OCP на опережение. Разработчик читает про Open‑Closed Principle и начинает защищаться от будущего, которого ещё нет:

А вдруг отчёт будет CSV, а не XLSX?
А вдруг отправлять нужно в Slack?
А вдруг появится PDF и выбор формата из админки?

Через час в репозитории:

interface FeedbackReportFormatter
 {
 	public function format(array $feedback): Report;
 }
class XlsxFeedbackReportFormatter implements FeedbackReportFormatter
 {
 	public function format(array $feedback): Report
 	{
     	// XLSX из уже отфильтрованных $feedback
 	}
 }

 class CsvFeedbackReportFormatter implements FeedbackReportFormatter
 {
 	public function format(array $feedback): Report
 	{
     	// CSV из уже отфильтрованных $feedback
 	}
 }

На code review всё красиво: интерфейсы, реализации, можно подменить экспортёр или отправителя. Формально похоже на OCP — но оси изменений ещё нет, защищать нечего. Но в production по‑прежнему один формат и один канал. Мы заплатили за гибкость, которой не пользуемся. Это в первую очередь YAGNI: строим под гипотезу, а не под требование. KISS тоже страдает — лишние абстракции усложняют чтение, тесты и онбординг, а альтернативных реализаций нет.

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

new XlsxFeedbackReportExporter();
new EmailFeedbackReportSender();

Потому что других вариантов просто не существует. В преждевременном варианте абстрагировали две оси сразу — формат и канал. В здоровой эволюции — по одной, когда каждая подтверждена требованиями. Антипаттерн: игнорировать OCP. Обратная крайность — «абстракции не нужны, сделаем в одном методе»:

class FeedbackReportService
 {
 public function send(string $type): void
 {
 $feedback = $this→repository→getAll();

 match ($type) {
 'weekly_xlsx' => $this→mailer→send(
 FeedbackXlsxReport::generate($feedback)
 ),
 'weekly_csv' => $this→mailer→send(
 FeedbackCsvReport::generate($feedback)
 ),
 'monthly_xlsx' => $this→mailer→send(
 MonthlyFeedbackXlsxReport::generate($feedback)
 ),
 default => throw new InvalidArgumentException(“Unknown report: {$type}”),
 };
 }
 }

Пока в match один‑два кейса — терпимо. Когда появляется третий (monthly_xlsx в том же методе) — это уже сигнал: варианты отчётов начинают копиться в одном классе. Ключи вроде weekly_xlsx заодно склеивают период и формат — тот же комбинаторный зуд, от которого уходим в рабочем решении ниже. Каждый новый тип отчёта меняет один и тот же класс:

· растёт риск сломать старые сценарии;

· тесты раздуваются комбинаторикой веток;

· сервис начинает знать все варианты продукта;

· разные команды правят один файл и мешают друг другу.

Это нарушение OCP не потому, что «нет интерфейса», а потому что новая функциональность постоянно требует переписывать существующую.

Где проходит граница: ось изменений

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

  • отчёты в нескольких форматах;

  • уведомления в нескольких каналах;

  • правила расчёта для разных клиентов;

  • способы оплаты, подключаемые независимо.

Один XLSX и один email — оси пока нет. Есть только гипотеза, а не подтверждённое требование.

OCP и YAGNI — соседи, но не синонимы. YAGNI отвечает на вопрос «нужно ли это сейчас?» — не делаем варианты, которых бизнес не просил. OCP — на другой: «если варианты уже есть, можно ли добавить новый, не переписывая ядро?» Интерфейс «на всякий случай» при одном XLSX — YAGNI. match на пятый формат в одном сервисе — уже проблема OCP: ядро меняется при каждом новом требовании. Это разные тесты.

Разумный старт: без интерфейсов

Для единственного требования достаточно простого кода:

class FeedbackReportService
 {
 public function __construct(
 private FeedbackRepository $repository,
 private ReportMailer $mailer
 ) {}

 public function sendWeeklyReport(): void
 {
 $feedback = $this→repository→getForPeriod(DateRange::lastWeek());
 $report = FeedbackXlsxReport::generate($feedback);
 $this→mailer→send($report);
 }
 }

Не космолёт и не каша:

· FeedbackRepository — хранение и выдача отзывов;

· FeedbackXlsxReport — формирование отчёта;

· ReportMailer — отправка письма;

· FeedbackReportService — склейка сценария.

На этом этапе OCP пока нечего защищать: один сценарий, одна причина менять класс, ось изменений не подтверждена. Принцип включается, когда вариативность становится постоянной и требования это подтверждают.

Эволюция: первая ось — форматы отчётов.

Через месяц бизнес просит ежедневный CSV для аналитиков в дополнение к еженедельному XLSX для менеджеров.

Появилась ось изменений: формат отчёта — XLSX и CSV. Периодичность другая (день vs неделя), но это параметр job’а, а не причина плодить комбинаторные классы вроде WeeklyXlsx… и DailyCsv…. Недельный CSV и ежедневный XLSX — те же форматтеры, другой DateRange в cron.

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

Тогда имеет смысл выделить точку расширения — контракт на форматирование отчёта:

interface FeedbackReportFormatter
 {
 public function format(array $feedback): Report;
 }

class XlsxFeedbackReportFormatter implements FeedbackReportFormatter
 {
 public function format(array $feedback): Report
 {
 // XLSX из уже отфильтрованных $feedback
 }
 }

 class CsvFeedbackReportFormatter implements FeedbackReportFormatter
 {
 public function format(array $feedback): Report
 {
 // CSV из уже отфильтрованных $feedback
 }
 }

Статический FeedbackXlsxReport::generate() из простого старта превращаем в класс‑форматтер, когда появляется второй формат.

Метод sendWeeklyReport() сужаем до универсального сценария: период и форматтер приходят аргументами, потому что разные job’ы комбинируют их по‑своему, а канал доставки пока один — ReportMailer остаётся в конструкторе.

class FeedbackReportService
 {
 public function __construct(
 private FeedbackRepository $repository,
 private ReportMailer $mailer
 ) {}

 public function send(DateRange $period, FeedbackReportFormatter $formatter): void
 {
 $feedback = $this→repository→getForPeriod($period);
 $report = $formatter→format($feedback);
 $this→mailer→send($report);
 }
 }

Здесь мы отделили причину изменения «формат отчёта» от сценария доставки — это и SRP, и OCP. Новый формат — отдельный класс, без правок сервиса. Гипотетический третий вариант — только чтобы показать механизм, не повод вводить PDF в коде заранее:

class PdfFeedbackReportFormatter implements FeedbackReportFormatter
 {
 public function format(array $feedback): Report
 {
 // PDF — если бизнес попросит
 }
 }

Старый код не трогаем. Новый добавляем рядом. Вот это и есть OCP в рабочем виде.

Важно: мы не начали с интерфейса. Пришли к нему после второго реального варианта — ось подтверждена требованиями. Путь «простое решение → наблюдение → аккуратное расширение» здоровее, чем «сначала платформа, потом требования».

Позже часть отчётов нужно слать в Slack, часть — на email.

Вторая ось: каналы доставки. ReportMailer из SRP‑части сужаем до контракта ReportSender:

interface ReportSender
 {
 public function send(Report $report): void;
 }

 class EmailReportSender implements ReportSender
 {
 public function send(Report $report): void
 {
 // отправка email
 }
 }

 class SlackReportSender implements ReportSender
 {
 public function send(Report $report): void
 {
 // отправка в Slack
 }
 }

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, конфиге или DI — без переписывания ядра:

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

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

ReportSender передаётся в метод, а не в конструктор: канал — per‑invocation (одна job шлёт в Slack, другая — в email). Период и форматтер тоже приходят снаружи — cron знает расписание и нужный формат. ReportSender и FeedbackReportFormatter передаются в метод явно: для двух‑трёх вариантов это проще, чем поднимать фабрику и резолвер. Когда правил выбора станет много — можно вынести сборку в отдельный слой. Но не раньше.

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

Даже с двумя осями изменений мы не добавляем:

  • AbstractFeedbackReport;

  • FeedbackReportFactoryInterface;

  • ReportSenderResolver;

  • ReportPipelineBuilder;

  • UniversalReportOrchestrator.

Задача этого не требует. Несколько форматов, несколько каналов, один сценарий «собрать → сгенерировать → отправить» — достаточно. Сложные правила расписания, прав доступа и настроек из админки — повод вернуться к архитектуре. Но когда они появятся, а не «на всякий случай».

Чеклист: когда OCP уже оправдан

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

1. Есть ли уже второй (или третий) реальный вариант поведения? Один вариант — интерфейс чаще добавляет файл, а не гибкость.

2. Третий ли раз вы правите одно и то же место? Повторяющиеся if/switch в одном классе — сигнал оси изменений.

3. Ломает ли новый кейс тесты старых сценариев? Если да — стабильное ядро пора отделить от вариантов. Тогда CsvFeedbackReportFormatter и XlsxFeedbackReportFormatter тестируют изолированно, а сервис — один раз с моками.

4. Меняют ли один файл разные команды с разными целями? OCP часто идёт рука об руку с SRP: разные причины изменений — разные модули.

5. Новый кейс похож на старый, но отличается деталями? Классический кандидат на полиморфизм вместо ветвления.

Это не математика, а практический фильтр: нашли ли вы ось изменений, которую пора вынести из ядра. YAGNI — отдельный вопрос на шаг раньше: не строим ли мы под фантазию.

Trade‑off: интерфейс не единственный ответ

OCP не требует interface в каждом файле — достаточно, чтобы ядро не менялось при добавлении варианта. В PHP точку расширения можно выразить callable’ом, union типов или простым map. Для форматов отчётов map с объектами‑форматтерами — валидный OCP уже при двух вариантах: новый формат = новый класс + одна строка в map, FeedbackReportService не трогаем. Типизация через общий контракт FeedbackReportFormatter:

$formatters = [
 'xlsx' => new XlsxFeedbackReportFormatter(),
 'csv' => new CsvFeedbackReportFormatter(),
 ];

 $formatter = $formatters[$format]
?? throw new InvalidArgumentException(“Unknown format: {$format}”);
 $feedback = $repository→getForPeriod(DateRange::lastWeek());
 $report = $formatter→format($feedback);

Период по‑прежнему снаружи — cron передаёт DateRange, map выбирает только формат. Rule of three здесь про момент выделения оси, а не про выбор между map и interface: не строим фабрику с одним XLSX. Когда форматов уже два‑три, map часто достаточен. Фабрика или resolver — когда правил выбора и комбинаций становится много.

Отдельные классы без map оправданы, когда:

· реализации живут в разных пакетах или командах;

· нужен контракт для тестов и подмены в DI;

· варианты подключают из разных мест приложения.

Выбор формы расширения — не догма SOLID, а соразмерность задаче. OCP про стабильность ядра, а не про обязательный interface в каждом файле.

Вывод

Open‑Closed Principle — не броня из интерфейсов на каждый класс и не запрет на рефакторинг.

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

  1. Пишем простой код под текущие требования.

  2. Следим, где повторяются изменения — появляется ось вариативности.

  3. Выделяем точку расширения там, где вариантов уже несколько.

  4. Добавляем новое поведение рядом, не ломая стабильный сценарий.

SOLID не отменяет KISS, YAGNI и здравый смысл. Хорошая архитектура — когда код меняют без боли и без ощущения, что вы случайно устроились инженером на орбитальную станцию.

Делитесь в комментариях: где OCP реально сэкономил время, а где принёс только лишние интерфейсы?

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

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

Автор: avalevich

Источник

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