SOLID на живых примерах: чек-лист перед продакшеном
Пять принципов SOLID полезны не как набор лозунгов, а как способ найти дорогие изменения до релиза. Разбираем PHP 8.3 на примере заказа, оплаты и скидок и собираем чек-лист для ревью.

Перед релизом сервиса заказов на PHP 8.3 один новый способ оплаты не должен заставлять менять расчёт скидки, отправку писем и десяток тестов. Если заставляет, проблема не в размере задачи: код связал несколько причин для изменений в один класс. Ниже — практический чек-лист SOLID для продакшена: с живыми фрагментами, ценой каждого решения и проверками, которые можно пройти на ревью.
Сначала найдите цену связности
Представим сервис оформления заказа. Он принимает корзину, выбирает скидку, списывает деньги, сохраняет заказ и отправляет письмо. На старте такой класс кажется удобным: всё находится в одном месте. На третьем способе оплаты он превращается в точку конфликта для нескольких разработчиков и источник регрессий.
<?php
final class OrderService
{
public function create(array $cart, string $paymentType): void
{
$total = $this->calculateTotal($cart);
if ($paymentType === 'card') {
// вызов API банка
} elseif ($paymentType === 'sbp') {
// вызов СБП
}
// запись заказа в базу
// отправка письма покупателю
}
private function calculateTotal(array $cart): int
{
// скидки, доставка и налоги смешаны в одном методе
return 0;
}
}
Перед продакшеном полезно не спрашивать «соответствует ли класс SOLID», а пройти пять более приземлённых проверок:
- можно ли добавить сценарий, изменив один модуль, а не цепочку несвязанных классов;
- можно ли протестировать расчёт без базы, сети и платёжного шлюза;
- понятно ли по типам, что именно разрешено делать зависимостям;
- не зависит ли бизнес-правило от конкретной библиотеки или HTTP-клиента;
- есть ли тест на ошибку интеграции, а не только на успешный путь.
Сноска. Связность — количество и сила зависимостей между частями системы. Чем больше причин менять класс приходит извне, тем дороже его изолированное тестирование и тем выше риск побочного эффекта.
Рабочий SOLID видно не по названию интерфейса, а по цене следующего изменения.
SRP: один класс — одна причина для изменения
Single Responsibility Principle не требует делать класс из каждой строки. Практический тест проще: назовите владельцев изменений. Расчёт суммы меняет продуктовый аналитик, оплату — интеграционная команда, письмо — маркетинг. Значит, эти правила не должны жить в одном сервисе.
<?php
final class OrderTotalCalculator
{
public function calculate(array $items, DiscountPolicy $discount): int
{
$subtotal = array_sum(array_map(
static fn (array $item): int => $item['price'] * $item['quantity'],
$items
));
return $subtotal - $discount->amountFor($subtotal);
}
}
interface DiscountPolicy
{
public function amountFor(int $subtotal): int;
}
final class NoDiscount implements DiscountPolicy
{
public function amountFor(int $subtotal): int
{
return 0;
}
}
Проверка перед релизом: если изменение шаблона письма требует запускать тесты расчёта корзины, граница всё ещё проведена плохо. Минус такого разделения — больше объектов и файлов. Это нормальная плата, если классы можно тестировать без внешних сервисов.
OCP: новое правило не должно переписывать старое
Open/Closed Principle особенно заметен в скидках. Цепочка if по типам клиентов быстро становится зоной риска: добавили промокод — случайно изменили расчёт для постоянных покупателей.
<?php
final class PercentageDiscount implements DiscountPolicy
{
public function __construct(private readonly int $percent)
{
}
public function amountFor(int $subtotal): int
{
return intdiv($subtotal * $this->percent, 100);
}
}
final class FixedDiscount implements DiscountPolicy
{
public function __construct(private readonly int $amount)
{
}
public function amountFor(int $subtotal): int
{
return min($this->amount, $subtotal);
}
}
Теперь калькулятор работает с контрактом скидки, а не знает все её виды. Цена подхода — необходимость заранее определить контракт и отдельно решить спорные правила: округление, отрицательный итог, совместимость нескольких скидок. Эти правила нельзя оставлять «на потом»: именно они чаще всего ломают пограничные заказы.
LSP: подстановка не должна менять смысл
Liskov Substitution Principle проверяет не синтаксис наследования, а обещания. Если интерфейс репозитория обещает сохранить заказ, реализация не может молча принимать только заказы со статусом paid. Если платёжный шлюз возвращает «успешно» до подтверждения банка, он нарушает смысл контракта, даже если метод называется правильно.
<?php
interface PaymentGateway
{
public function charge(int $amount, string $orderId): PaymentResult;
}
final class PaymentResult
{
public function __construct(
public readonly string $status,
public readonly ?string $transactionId,
) {
}
}
final class CardGateway implements PaymentGateway
{
public function charge(int $amount, string $orderId): PaymentResult
{
// Реальный вызов SDK должен переводиться в этот общий результат.
return new PaymentResult('paid', 'TRANSACTION_ID');
}
}
На ревью спросите: одинаковы ли условия до вызова, результат и ошибки у каждой реализации. Если один шлюз кидает исключение, другой возвращает false, а третий сообщает об оплате позже, это уже три разных контракта. Их надо выразить отдельными состояниями или отдельным асинхронным процессом, а не скрывать под одним методом.
ISP: интерфейс должен быть меньше клиента
Interface Segregation Principle ломается, когда всем интеграциям выдают один «универсальный» интерфейс с десятью методами. Сервис оформления использует оплату, а админка — возврат. Не заставляйте первый класс зависеть от операции, которая ему не нужна.
<?php
interface PaymentProcessor
{
public function charge(int $amount, string $orderId): PaymentResult;
}
interface RefundProcessor
{
public function refund(string $transactionId, int $amount): RefundResult;
}
final class CheckoutService
{
public function __construct(
private readonly PaymentProcessor $payments,
) {
}
public function pay(int $amount, string $orderId): PaymentResult
{
return $this->payments->charge($amount, $orderId);
}
}
Сигнал проблемы — заглушки вроде throw new LogicException('Not supported') в реализациях интерфейса. Такой метод существует ради формального соответствия, но не ради реального поведения. Разделение интерфейсов увеличивает число типов, зато локализует права и упрощает тестовые замены.
DIP: бизнес-код не должен знать SDK
Dependency Inversion Principle не означает «всё завернуть в интерфейс». Он означает, что бизнес-сценарий зависит от своего контракта, а конкретный SDK подключается снаружи.
| Подход | Что выигрываете | Чем платите | Когда выбирать |
|---|---|---|---|
| Вызов SDK внутри CheckoutService | Мало кода на старте | Сложные тесты, зависимость от API поставщика | Только для короткого прототипа |
| Адаптер под PaymentProcessor | Изолированные тесты и замена шлюза | Нужен отдельный класс и маппинг ошибок | Продакшен-интеграция |
| Общий платёжный сервис по сети | Единая интеграция для нескольких приложений | Сетевая задержка, отказ сервиса, операционные расходы | Несколько потребителей и независимый жизненный цикл |
В PHP 8.3 зависимость можно передать через конструктор, как в примере выше. Сборку конкретного адаптера оставьте композиционному корню приложения — месту, где создаются объекты. Тогда тест расчёта не требует ключа, сети и настоящего платёжного аккаунта.
Чек-лист перед продакшеном
Пройдите его на конкретном сценарии, например «покупатель оплачивает заказ картой и получает скидку». Ответ «нет» не всегда означает, что код надо переписать. Важно зафиксировать риск и цену изменения.
- SRP. Названы ли разные причины изменения? Проверка: изменение письма не затрагивает калькулятор суммы и платёжный адаптер.
- OCP. Добавляется ли новое правило через отдельную реализацию или конфигурацию? Проверка: тест старой скидки проходит без изменения её класса.
- LSP. Все реализации соблюдают одинаковые предусловия, результат и модель ошибок? Проверка: контрактные тесты запускаются для каждого платёжного шлюза.
- ISP. Есть ли методы, которые клиент не вызывает или помечает как неподдерживаемые? Проверка: интерфейс клиента содержит только операции его сценария.
- DIP. Можно ли выполнить бизнес-тест без базы, HTTP и SDK? Проверка: внешние зависимости заменяются тестовыми объектами на границе приложения.
- Ошибки. Что произойдёт после тайм-аута и повторного запроса? Проверка: заказ не оплачивается дважды, а повторная попытка имеет понятный статус.
- Наблюдаемость. Можно ли связать заказ, запрос к шлюзу и ошибку по идентификатору? Проверка: в логах нет секретов, но есть безопасный идентификатор операции и длительность вызова.
Что ломается чаще всего
Интерфейс появился раньше поведения
Создать PaymentInterface недостаточно. Если в нём те же семь методов, что и в SDK, это не абстракция, а копия чужого API. Оставьте только операции, которые нужны вашему сценарию, и переведите чужие исключения в ошибки домена.
Наследование используют вместо композиции
Класс VipOrderService extends OrderService часто переопределяет половину методов и зависит от внутренних деталей родителя. Для скидок и способов оплаты обычно безопаснее передать стратегию через конструктор. Наследование оставляйте там, где подтип действительно сохраняет все обещания базового типа.
Тестируют только happy path
SOLID не спасает от ошибки в правилах. Нужны проверки нулевой суммы, скидки больше стоимости, повторной оплаты, тайм-аута и ответа шлюза с неизвестным статусом. Конкретный набор зависит от контракта интеграции, поэтому сверяйте его с документацией поставщика.
Что делать дальше
Возьмите один самый дорогой сценарий в продакшене и нарисуйте его цепочку: контроллер, бизнес-сервис, база, внешние API. Для каждого узла запишите причину изменения, контракт и способ тестирования без сети. Затем замерьте время и долю ошибок до рефакторинга, повторите замер после и отдельно проверьте повторные запросы. SOLID здесь не финишная оценка кода, а способ сделать следующую замену предсказуемой.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: PHP Manual: Object-Oriented Programming, PHP Manual: Interfaces
Частые вопросы
Нужно ли внедрять все пять принципов SOLID в каждом проекте?
Почему интерфейс не делает код автоматически соответствующим SOLID?
Что делать, если у меня PHP 8.2 или более старая версия?
Как понять, что рефакторинг не ухудшил продакшен?
Нужно ли выносить платёжный адаптер в отдельный микросервис?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.