SOLID в реальном мире. OCP без «давайте сразу сделаем расширяемо»
Дисклеймер: статья для разработчиков уровня 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 реально сэкономил время, а где принёс только лишние интерфейсы?
Что еще почитать
А еще подписывайтесь на канал нашего СТО Андрея Непряхина.
Автор: avalevich

