Принцип открытости/закрытости (Open/Closed Principle)
Модуль открыт для расширения и закрыт для изменения: новый способ оплаты подключается к кассе без правки её кода.
Проблема
Касса принимает наличные и карту. Комиссия каждого способа зашита в match внутри CashRegister:
final class CashRegister
{
public function pay(string $method, int $amountCents): Payment
{
$fee = match ($method) {
'cash' => 0,
'card' => intdiv($amountCents * 2, 100),
default => throw new UnknownPaymentMethod($method),
};
return new Payment($method, $amountCents, $fee);
}
}Магазин подключает СБП. Нужно открыть CashRegister, дописать ветку, перезапустить все тесты кассы и надеяться, что соседние ветки не задеты. Через год в match десять способов, у каждого свои параметры, и касса знает о договорах со всеми банками. Каждый новый способ оплаты - правка класса, который уже работает в продакшене.
Решение
Выделить точку расширения - интерфейс PaymentMethod с кодом способа и расчётом комиссии. Каждый способ - отдельная реализация: CashPayment, CardPayment. CashRegister получает список способов при создании, находит нужный по коду и делегирует ему комиссию. Новый способ - новый класс и одна строка там, где касса собирается; сам CashRegister не меняется.
"Закрыт" не значит "заморожен навсегда": касса меняется, когда меняется её собственная логика (например, появляется чек). Закрыт он от изменений, причина которых живёт в другом месте - в правилах конкретного банка.
Структура
Касса строит карту "код -> способ" один раз в конструкторе, pay только ищет и делегирует. Неизвестный код - отдельное исключение UnknownPaymentMethod, а не null: вызывающий код решает, что показать покупателю. Payment хранит, сколько заплачено и сколько удержал банк, netCents - сколько дойдёт до магазина.
Как это собирается
Список способов оплаты собирается в одном месте - в DI-контейнере или main: там создаются CashPayment и CardPayment(2) с комиссией из договора, и все они передаются в CashRegister. Дальше обработчик оформления заказа получает из запроса код способа и сумму корзины и вызывает pay. В примере три запроса: наличные, карта и неизвестный "bonus", который касса отвергает с понятной ошибкой. Подключение СБП в таком приложении - новый класс и ещё один аргумент при сборке кассы.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | interface PaymentMethod, касса - final readonly class, неизвестный код - UnknownPaymentMethod extends DomainException |
| Go | Интерфейс из двух методов; NewCashRegister(methods ...PaymentMethod), ошибка - сентинел ErrUnknownMethod через %w |
| Python | Protocol без наследования: подходит любой объект с code и fee_cents; UnknownPaymentMethod(LookupError) |
| TypeScript | Структурный interface PaymentMethod: новый способ в тесте - объектный литерал, без класса |
Запуск: make test T=principles/solid/open-closed
Плюсы и минусы
Главный тест - "новый способ без правки кассы": способ СБП с комиссией 0,4% объявлен прямо в тесте (анонимный класс, структура, литерал), касса о нём никогда не слышала и всё равно считает правильно. Остальные проверяют делегирование для наличных и карты, отказ на неизвестный код и валидацию процента комиссии при создании.
- ✅ Новый способ оплаты не трогает проверенный код кассы.
- ✅ Правила каждого банка изолированы в своём классе и тестируются отдельно.
- ✅ Набор способов задаётся конфигурацией сборки: в разных странах разные списки, касса одна.
- ❌ Точку расширения нужно угадать заранее. Если завтра способу понадобится не только комиссия, но и возврат, интерфейс всё равно придётся менять.
- ❌ Логика размазана по классам: чтобы понять все комиссии, нужно открыть каждый.
Когда не применять
- Вариантов два, и новые не ожидаются -
matchчестнее и короче. - Точка расширения придумана "на будущее", без реального второго варианта: абстракция под один случай почти всегда оказывается не той.
- Код внутри одной команды и одного деплоя: поправить
matchдешевле, чем поддерживать иерархию.
Отличия между языками
- PHP - контракт выражается только интерфейсом,
implementsобязателен. Анонимный класс в тесте показывает, что новый способ не требует даже файла. - Go - интерфейс удовлетворяется неявно:
sbpPaymentв тесте не объявляет, что онPaymentMethod, у него просто есть нужные методы. Ошибка неизвестного кода - значение, его проверяют черезerrors.Is. - Python -
Protocolописывает форму, а не родителя.CashPaymentи класс СБП в тесте ни от чего не наследуются. - TypeScript - структурная типизация: любой объект с
codeиfeeCentsуже способ оплаты, поэтому расширение в тесте - литерал в одну строку.
Связанные темы
- SOLID - все пять принципов
- Стратегия - тот же приём для алгоритма, выбранного во время работы
- Принцип инверсии зависимостей - касса зависит от абстракции, а не от конкретных банков
- Декоратор - расширение поведения обёрткой, без правки обёрнутого класса