handbookНаписать

principles/solid/open-closed/README.md · тема · 4 языка · 23 теста

Принцип открытости/закрытости (Open/Closed Principle) ​

Модуль открыт для расширения и закрыт для изменения: новый способ оплаты подключается к кассе без правки её кода.

Проблема ​

Касса принимает наличные и карту. Комиссия каждого способа зашита в match внутри CashRegister:

php
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 не меняется.

"Закрыт" не значит "заморожен навсегда": касса меняется, когда меняется её собственная логика (например, появляется чек). Закрыт он от изменений, причина которых живёт в другом месте - в правилах конкретного банка.

Структура ​

CashRegister

-methods: map<string, PaymentMethod>

+pay(code, amountCents) : Payment

«interface»

PaymentMethod

+code() : string

+feeCents(amountCents) : int

CashPayment

CardPayment

-feePercent: int

Payment

+method: string

+amountCents: int

+feeCents: int

+netCents() : int

Касса строит карту "код -> способ" один раз в конструкторе, pay только ищет и делегирует. Неизвестный код - отдельное исключение UnknownPaymentMethod, а не null: вызывающий код решает, что показать покупателю. Payment хранит, сколько заплачено и сколько удержал банк, netCents - сколько дойдёт до магазина.

Как это собирается ​

Список способов оплаты собирается в одном месте - в DI-контейнере или main: там создаются CashPayment и CardPayment(2) с комиссией из договора, и все они передаются в CashRegister. Дальше обработчик оформления заказа получает из запроса код способа и сумму корзины и вызывает pay. В примере три запроса: наличные, карта и неизвестный "bonus", который касса отвергает с понятной ошибкой. Подключение СБП в таком приложении - новый класс и ещё один аргумент при сборке кассы.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPinterface PaymentMethod, касса - final readonly class, неизвестный код - UnknownPaymentMethod extends DomainException
GoИнтерфейс из двух методов; NewCashRegister(methods ...PaymentMethod), ошибка - сентинел ErrUnknownMethod через %w
PythonProtocol без наследования: подходит любой объект с 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 уже способ оплаты, поэтому расширение в тесте - литерал в одну строку.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS