Table of Contents
SOLID – это не магическое заклинание, а набор из пяти принципов, которые помогают не превратить код в липкий спагетти-комбайн через год после старта проекта.
Это аббревиатура:
S – Single Responsibility Principle
O – Open/Closed Principle
L – Liskov Substitution Principle
I – Interface Segregation Principle
D – Dependency Inversion Principle
Single Responsibility.
Класс должен иметь одну причину для изменения. Не “делать одну функцию”, а иметь одну ответственность.
Плохой пример:
class UserManager
{
public function createUser() {}
public function sendEmail() {}
public function logToFile() {}
}
Этот класс управляет пользователями, отправляет письма и пишет логи. Три причины для изменения. Три разные области.
Хороший вариант – разделить: UserService, EmailService, Logger. Каждый отвечает за своё.
Open/Closed.
Класс должен быть открыт для расширения, но закрыт для изменения.
Это означает: добавлять новое поведение через новые классы, а не переписывать старые.
Если ты добавляешь новый способ оплаты и лезешь переписывать старый код с кучей if-ов – ты нарушаешь принцип.
Если ты просто добавил новый класс, реализующий интерфейс PaymentMethod – ты соблюдаешь его.
Допустим, у тебя есть сервис оплаты:
class PaymentService
{
public function pay(string $type, float $amount): void
{
if ($type === 'card') {
echo "Оплата картой: $amount\n";
} elseif ($type === 'paypal') {
echo "Оплата через PayPal: $amount\n";
}
}
}
Работает? Да.
Но теперь бизнес говорит: добавляем крипту.
Что ты делаешь? Лезешь в этот же класс и дописываешь:
elseif ($type === 'crypto') {
echo "Оплата криптой: $amount\n";
}
Каждый новый способ оплаты заставляет тебя менять существующий код.
Класс не закрыт для изменения. Ты постоянно его трогаешь. Растет лес if-ов. Растет риск сломать старое.
Теперь делаем правильно.
Шаг 1. Вводим абстракцию.
interface PaymentMethod
{
public function pay(float $amount): void;
}
Шаг 2. Конкретные реализации.
class CardPayment implements PaymentMethod
{
public function pay(float $amount): void
{
echo "Оплата картой: $amount\n";
}
}
class PaypalPayment implements PaymentMethod
{
public function pay(float $amount): void
{
echo "Оплата через PayPal: $amount\n";
}
}
Шаг 3. Сервис работает с абстракцией.
class PaymentService
{
private PaymentMethod $paymentMethod;
public function __construct(PaymentMethod $paymentMethod)
{
$this->paymentMethod = $paymentMethod;
}
public function pay(float $amount): void
{
$this->paymentMethod->pay($amount);
}
}
Теперь добавляем крипту.
Мы НЕ трогаем PaymentService вообще.
Просто создаем новый класс:
class CryptoPayment implements PaymentMethod
{
public function pay(float $amount): void
{
echo "Оплата криптой: $amount\n";
}
}
И используем:
$payment = new CryptoPayment();
$service = new PaymentService($payment);
$service->pay(100);
Вот это и есть Open/Closed в действии.
PaymentService:
- открыт для расширения, потому что можно передать новую реализацию
- закрыт для изменения, потому что его код менять не нужно
И вот тут начинается архитектурная чистота. Старый код стабилен. Новое поведение добавляется через новые классы. Риск минимальный.
Если говорить глубже – Open/Closed почти всегда достигается через:
- абстракции
- полиморфизм
- композицию
Без них принцип не работает.
Liskov Substitution
Любой дочерний класс должен полностью заменять родительский без поломки логики.
Если наследник не ведет себя как родитель по контракту – это плохое наследование.
Сначала нормальный пример.
class Bird
{
public function fly(): void
{
echo "Flying\n";
}
}
Теперь:
class Sparrow extends Bird
{
}
Все ок. Везде, где ожидается Bird, можно передать Sparrow.
Теперь плохой пример.
class Penguin extends Bird
{
public function fly(): void
{
throw new Exception("Penguins can't fly");
}
}
Формально Penguin – это Bird.
Но по факту он ломает поведение.
Если где-то есть код:
function makeBirdFly(Bird $bird): void
{
$bird->fly();
}
И ты передаешь Penguin – программа падает.
Вот это нарушение Liskov. Потомок не соответствует ожиданиям родителя.
Ключевая мысль: наследник не должен усиливать ограничения или менять контракт.
Если родитель говорит: “я умею летать”, потомок не может сказать: “я не умею”.
Значит модель неправильная.
Правильнее было бы сделать так:
abstract class Bird
{
}
А способность летать вынести в отдельный интерфейс:
interface Flyable
{
public function fly(): void;
}
И тогда:
class Sparrow extends Bird implements Flyable
{
public function fly(): void
{
echo "Flying\n";
}
}
class Penguin extends Bird
{
}
Теперь логика честная. Мы не заставляем пингвина притворяться тем, кем он не является.
Interface Segregation Principle
Клиенты не должны зависеть от методов, которые они не используют.
Проще: лучше несколько маленьких интерфейсов, чем один жирный универсальный комбайн.
Сейчас покажу, где обычно ломаются.
Допустим, кто-то придумал такой интерфейс:
interface Worker
{
public function work(): void;
public function eat(): void;
public function sleep(): void;
}
Звучит логично? Типа “рабочий организм”.
Теперь появляется человек:
class Human implements Worker
{
public function work(): void {}
public function eat(): void {}
public function sleep(): void {}
}
Все ок.
А теперь появляется робот.
class Robot implements Worker
{
public function work(): void {}
public function eat(): void
{
// роботы не едят
}
public function sleep(): void
{
// роботы не спят
}
}
Вот тут проблема. Robot вынужден реализовывать методы, которые ему не нужны.
Интерфейс слишком жирный. Он заставляет классы притворяться.
Теперь делаем правильно.
Разделяем интерфейсы:
interface Workable
{
public function work(): void;
}
interface Eatable
{
public function eat(): void;
}
interface Sleepable
{
public function sleep(): void;
}
Теперь:
class Human implements Workable, Eatable, Sleepable
{
public function work(): void {}
public function eat(): void {}
public function sleep(): void {}
}
class Robot implements Workable
{
public function work(): void {}
}
Чисто. Никто не реализует лишнего.
Вот это и есть Interface Segregation.
Почему это важно?
Потому что большие интерфейсы:
- создают лишние зависимости
- увеличивают связанность
- заставляют менять код в местах, где не нужно
Если ты добавишь новый метод в жирный интерфейс, ты обязан изменить ВСЕ классы, которые его реализуют. Даже если им этот метод не нужен.
Этот принцип особенно важен в больших системах, где один интерфейс может использоваться десятками классов. Один лишний метод – и ты запускаешь цепную реакцию правок.
Dependency Inversion.
Dependency Inversion Principle (DIP), или принцип инверсии зависимостей, — это принцип SOLID, требующий, чтобы высокоуровневые модули (логика приложения) не зависели от низкоуровневых (базы данных, API), а оба зависели от абстракций (интерфейсов). В PHP это достигается через передачу интерфейсов в классы, что обеспечивает гибкость, тестируемость и слабую связанность кода.
Сначала плохой пример.
class MySqlDatabase
{
public function save(string $data): void
{
echo "Saving to MySQL: $data\n";
}
}
class ReportService
{
private MySqlDatabase $database;
public function __construct()
{
$this->database = new MySqlDatabase();
}
public function generate(): void
{
$this->database->save("Report data");
}
}
Что тут не так?
ReportService – это логика верхнего уровня.
Он жестко привязан к MySqlDatabase.
Если завтра нужно сохранять в PostgreSQL или в файл – придется лезть в ReportService и менять код.
Он зависит от детали.
Теперь делаем правильно.
Шаг 1. Вводим абстракцию.
interface Storage
{
public function save(string $data): void;
}
Шаг 2. Реализация.
class MySqlStorage implements Storage
{
public function save(string $data): void
{
echo "Saving to MySQL: $data\n";
}
}
Шаг 3. Сервис зависит от абстракции.
class ReportService
{
private Storage $storage;
public function __construct(Storage $storage)
{
$this->storage = $storage;
}
public function generate(): void
{
$this->storage->save("Report data");
}
}
Шаг 4. Сборка.
$storage = new MySqlStorage();
$service = new ReportService($storage);
$service->generate();
Теперь ReportService не знает, что там внутри. MySQL, Redis, файл, API – ему все равно.
Он зависит от интерфейса Storage.
Вот это и есть инверсия зависимостей.
Раньше было так:
ReportService → MySqlDatabase
Теперь:
ReportService → Storage
MySqlStorage → Storage
Оба зависят от абстракции.
Почему это называется “инверсия”?
Потому что в наивной архитектуре высокоуровневый код зависит от низкоуровневого.
Здесь мы переворачиваем направление зависимости. Детали подстраиваются под контракт, а не наоборот.
И вот где начинается магия.
Этот принцип делает возможным:
- легкое тестирование (можно подставить mock)
- замену инфраструктуры без изменения бизнес-логики
- расширяемость
Это фундамент dependency injection контейнеров, которые ты видишь в Laravel, Symfony и т.д.
И важно понимать одну вещь. Dependency Injection – это способ реализации принципа. А Dependency Inversion – это сам принцип.
Можно внедрять зависимости через конструктор, через сеттер, через контейнер – это детали. Суть в том, чтобы верхний уровень не знал о конкретных классах нижнего уровня.