Дисклеймер: статья для разработчиков уровня junior и middle, которые знакомятся с принципами чистого кода и SOLID. Примеры кода — PHP 8.1+.
Всем доброго дня! На связи Валевич Артем — тимлид в компании AGIMA. В первой части серии мы разобрали Single Responsibility Principle на кейсе системы отзывов и пришли к простому выводу:
SOLID нужен не для того, чтобы плодить классы ради классов.

Сегодня — 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 — не броня из интерфейсов на каждый класс и не запрет на рефакторинг.
Практичный подход:
Пишем простой код под текущие требования.
Следим, где повторяются изменения — появляется ось вариативности.
Выделяем точку расширения там, где вариантов уже несколько.
Добавляем новое поведение рядом, не ломая стабильный сценарий.
SOLID не отменяет KISS, YAGNI и здравый смысл. Хорошая архитектура — когда код меняют без боли и без ощущения, что вы случайно устроились инженером на орбитальную станцию.
Делитесь в комментариях: где OCP реально сэкономил время, а где принёс только лишние интерфейсы?
Что еще почитать
А еще подписывайтесь на канал нашего СТО Андрея Непряхина.
vandy
Поправьте пож форматирование кода, неудобно читать
avalevich Автор
Поправил форматирование, спасибо!