SOLID без догм: пять принципов на примерах PHP
SOLID часто повторяют как список заповедей, хотя это пять разных инструментов для разных задач. Разбираем SRP, OCP, LSP, ISP и DIP на коде PHP: где принцип снимает боль, а где превращает простую задачу в лишнюю абстракцию.

SOLID придумали не как единый принцип, а как пять отдельных наблюдений Роберта Мартина о разных источниках хрупкости в объектно-ориентированном коде. Их объединили в одну аббревиатуру для удобства запоминания, и это сослужило плохую службу: многие читают SOLID как общий лозунг «пишите код правильно», хотя каждая буква отвечает на свой узкий вопрос. Полезно разбирать их по отдельности и проверять на конкретном коде, а не держать в голове как абстрактный идеал.
Single Responsibility: один повод для изменения
Принцип единственной обязанности часто пересказывают как «один класс, одна функция», и это упрощение приводит к бессмысленному дроблению кода на крошечные классы. Формулировка самого Мартина другая: у класса должен быть один повод для изменения, то есть он должен отвечать перед одним «актором», одной группой заинтересованных сторон, чьи требования могут меняться независимо от остальных.
Классический пример нарушения: класс заказа, который одновременно считает итоговую сумму, формирует письмо подтверждения и сохраняет запись в базу. На первый взгляд все три действия логично связаны с заказом. На практике изменения приходят из разных источников: бухгалтерия меняет формулу скидки, маркетинг переписывает текст письма, администратор базы меняет схему таблицы. Каждое из этих изменений заставляет трогать один и тот же класс, и при этом есть риск случайно задеть логику, которая вообще не должна была меняться.
final class Order
{
public function calculateTotal(): float { /* ... */ }
public function sendConfirmationEmail(): void { /* ... */ }
public function saveToDatabase(): void { /* ... */ }
}
// После разделения по поводам изменения
final class Order
{
public function calculateTotal(): float { /* ... */ }
}
final class OrderNotifier
{
public function sendConfirmation(Order $order): void { /* ... */ }
}
final class OrderRepository
{
public function save(Order $order): void { /* ... */ }
}После разделения каждый класс отвечает одной группе причин для изменения: расчёт суммы меняется, когда меняется бизнес-правило ценообразования, отправка письма меняется, когда маркетинг правит шаблон, а сохранение меняется, когда меняется схема хранения. Важно не путать это с запретом на классы из нескольких методов: класс Order вполне может иметь десяток методов, если все они обслуживают один и тот же повод для изменения, а именно модель заказа как таковую.
Open-Closed: открыт для расширения, закрыт для изменения
Принцип требует, чтобы добавление нового поведения не заставляло переписывать существующий проверенный код. На практике это чаще всего означает замену длинной цепочки switch или if на полиморфизм: вместо того чтобы дописывать новый case в существующую функцию при каждом новом типе, система принимает новую реализацию интерфейса, а старый код остаётся нетронутым.
interface PaymentMethod
{
public function pay(float $amount): void;
}
final class CardPayment implements PaymentMethod
{
public function pay(float $amount): void { /* ... */ }
}
final class CryptoPayment implements PaymentMethod
{
public function pay(float $amount): void { /* ... */ }
}
final class Checkout
{
public function __construct(private readonly PaymentMethod $method) {}
public function process(float $amount): void
{
$this->method->pay($amount);
}
}Класс Checkout теперь не знает, сколько способов оплаты существует, и не требует правок при добавлении нового способа. Это не отменяет условные конструкции вообще: там, где новый вариант появляется раз в несколько лет и сам список фиксирован, обычный match проще и честнее, чем интерфейс с единственной альтернативной реализацией, написанной «на будущее».
| Принцип | Что проверяет | Типичное неверное применение |
|---|---|---|
| SRP | Сколько причин для изменения у класса | Дробление на классы по одному методу вместо дробления по поводам изменения |
| OCP | Нужно ли менять существующий код для нового поведения | Интерфейс под единственную реализацию, которая никогда не появится вторая |
| LSP | Можно ли заменить базовый тип наследником без неожиданностей | Наследник, который ослабляет гарантии родителя или бросает новые исключения |
| ISP | Заставляет ли интерфейс реализовывать ненужные методы | Один большой интерфейс репозитория на все операции сразу |
| DIP | Зависит ли модуль от абстракции, а не от конкретной реализации | Жёсткая зависимость сервиса от конкретного драйвера базы данных |
Liskov Substitution: наследник не должен удивлять
Принцип подстановки Лисков формулируется строго: если S является подтипом T, то объекты типа T в программе можно заменить объектами типа S без изменения поведения программы. На практике это проверяется вопросом: можно ли передать наследника туда, где ожидается родитель, и получить ожидаемый результат, а не исключение или испорченное состояние.
Частое нарушение в PHP-коде: класс коллекции только для чтения, который наследуется от изменяемой коллекции и переопределяет метод add() так, что он бросает исключение. Формально это валидное наследование по синтаксису, но любой код, который получает объект через тип родителя и вызывает add(), неожиданно падает именно на этом конкретном наследнике, хотя по сигнатуре метод обязан был сработать.
Лечение в таком случае не в том, чтобы разрешить add() бросать исключение у части наследников, а в том, чтобы развести иерархию на два отдельных интерфейса: ReadableCollection с методом чтения и WritableCollection, который расширяет его методом add(). Класс, который реально не умеет добавлять элементы, просто не реализует второй интерфейс, и несовместимость становится видна на уровне типов, а не всплывает исключением в случайный момент выполнения.
Interface Segregation: не заставляй реализовывать лишнее
Принцип разделения интерфейса говорит, что клиенты не должны зависеть от методов, которые они не используют. На практике это чаще всего проявляется в большом интерфейсе репозитория, где есть методы и для чтения, и для записи, и для массового импорта, хотя конкретному потребителю нужен только один из них.
Если сервис отчётов зависит от интерфейса UserRepository с десятком методов, включая bulkImport() и deleteAll(), то при тестировании этого сервиса придётся создавать заглушку для всех десяти методов, даже если реально вызывается только findActive(). Разделение интерфейса на узкие роли, например UserReader и UserWriter, позволяет сервису отчётов зависеть только от того, что ему действительно нужно.
throw new \LogicException('не должно вызываться'). Это значит, что интерфейс слишком широкий для своего потребителя.Dependency Inversion: зависимость от абстракции, а не от деталей
Принцип инверсии зависимостей часто путают с внедрением зависимостей через контейнер, хотя это разные вещи. DIP про направление зависимости между модулями: модуль верхнего уровня, содержащий бизнес-логику, не должен зависеть от модуля нижнего уровня, содержащего детали реализации, например конкретную базу данных. Вместо этого оба модуля должны зависеть от общей абстракции.
interface OrderStorage
{
public function save(Order $order): void;
}
final class MySqlOrderStorage implements OrderStorage
{
public function save(Order $order): void { /* запрос к MySQL */ }
}
final class OrderService
{
public function __construct(private readonly OrderStorage $storage) {}
public function place(Order $order): void
{
$this->storage->save($order);
}
}Класс OrderService знает только про интерфейс OrderStorage и никогда не импортирует класс конкретной базы данных. Это позволяет подменить хранилище на другое, например на временное в памяти для тестов, без единой правки в бизнес-логике. Контейнер внедрения зависимостей, PHP-DI или встроенный в фреймворк, просто автоматизирует подстановку конкретной реализации, но сам DIP работает и без контейнера, если зависимости передаются явно через конструктор.
Когда SOLID создаёт больше проблем, чем решает
Все пять принципов сформулированы для систем, которые растут и меняются много лет силами нескольких команд. Применённые буквально к скрипту на триста строк, который решает одну задачу и живёт неделю, они превращают простой код в набор интерфейсов с единственной реализацией каждый, где абстракция не защищает от будущих изменений, потому что эти изменения никогда не наступят.
- Интерфейс с ровно одной реализацией, которая никогда не менялась за историю проекта, это кандидат на удаление, а не образец DIP
- Класс, разбитый на пять подклассов по SRP, но реально меняющийся всегда целиком одним коммитом, вероятно, разбит по формальному, а не содержательному признаку
- Если тест для нового поведения требует переписать пять моков под интерфейсы, которые никто больше не реализует, абстракция обслуживает саму себя, а не задачу
Разумный порядок применения на практике: сначала написать прямое решение без заранее придуманных интерфейсов, и только когда реально появляется вторая реализация, второй повод для изменения или второй потребитель с другими требованиями, выделять абстракцию под конкретно этот случай. SOLID описывает признаки здоровой архитектуры постфактум, а не чеклист, который нужно закрыть до того, как в коде появилась хоть одна строчка логики.
Частые вопросы
Нужно ли применять все пять принципов SOLID в каждом проекте?
Нет. Это пять отдельных диагностических вопросов, а не обязательный чеклист. Небольшой проект с одним автором и коротким жизненным циклом может спокойно обойтись без части из них.
В чём разница между DIP и внедрением зависимостей через контейнер?
DIP описывает направление зависимости между модулями: от деталей к абстракции. Контейнер внедрения зависимостей просто автоматически подставляет конкретную реализацию туда, где явно описана абстракция, и не является обязательным условием для DIP.
Как понять, что интерфейс нарушает принцип разделения интерфейса?
Если заглушка интерфейса для теста содержит методы с единственной строкой исключения «не должно вызываться», интерфейс слишком широк для конкретного потребителя и стоит разбить его на более узкие роли.



