SOLID в PHP: пять принципов на одном примере

SOLID — это пять правил проектирования классов, которые отвечают на вопрос «как писать код, который легко менять, не ломая»: SRP, OCP, LSP, ISP, DIP. Каждый принцип — ответ на конкретную боль: SRP — «класс делает всё сразу», OCP — «каждая новая фича правит старый код», LSP — «наследник ломает поведение родителя», ISP — «интерфейс заставляет реализовывать лишнее», DIP — «код привязан к конкретной реализации». Понимать их нужно не для того, чтобы выучить и называть аббревиатуры, а чтобы видеть, где класс начинает «пахнуть», до того как он разросся.


Зачем это нужно

Боль из практики: в проекте появился класс OrderProcessor — он считал скидку, отправлял письмо покупателю и писал лог. Сначала это выглядело удобно: всё в одном месте.

php
// ПЛОХО: класс-монстр — три причины меняться в одном классе
class OrderProcessor
{
    public function process(Order $order): void
    {
        // скидка
        if ($order->user->isVip()) {
            $order->total *= 0.9;
        }

        // сохранение
        $order->save();

        // письмо
        Mail::raw('Ваш заказ оформлен', function ($m) use ($order) {
            $m->to($order->user->email)->send();
        });

        // лог
        Log::info('Заказ оформлен', ['id' => $order->id]);
    }
}

Через два месяца: нужно добавить скидку по промокоду — правим OrderProcessor. Нужен другой текст письма — правим OrderProcessor. Нужен расчёт скидки в другом месте проекта — копипастим кусок, потому что он заперт внутри метода. Каждый чих правит класс, который при этом тестируется только целиком. Это и есть цена нарушения SRP — и её видно в коде:

php
// ХОРОШО: каждый класс — одна причина для изменения
class DiscountCalculator
{
    public function calculate(User $user, float $total): float
    {
        return $user->isVip() ? $total * 0.9 : $total;
    }
}

class OrderProcessor
{
    public function __construct(
        private DiscountCalculator $discounts,
        private OrderRepository    $orders,
        private Mailer             $mailer,
        private Logger             $logger,
    ) {}

    public function process(Order $order): void
    {
        $order->total = $this->discounts->calculate($order->user, $order->total);
        $this->orders->save($order);
        $this->mailer->sendOrderConfirmation($order);
        $this->logger->info('Заказ оформлен', ['id' => $order->id]);
    }
}

Скидка поменялась — правим один класс. В тесте OrderProcessor мокаем три зависимости — никаких писем и базы.

Как это работает

Разбираем все пять принципов на одном и том же примере — платежах.

S — Single Responsibility: одна ответственность

Принцип: у класса одна причина для изменения. «Ответственность» = «причина править класс».

php
// ПЛОХО: генерация отчёта + форматирование + сохранение
class SalesReport
{
    public function generate(): string
    {
        $data = DB::table('orders')->whereMonth('created_at', now()->month)->get();
        $csv = '';
        foreach ($data as $row) {
            $csv .= implode(',', (array)$row) . "\n"; // формат CSV вшит
        }
        file_put_contents('report.csv', $csv);         // хранение вшито
        return $csv;
    }
}
php
// ХОРОШО: три класса, три причины меняться — одна у каждого
class SalesReportQueryBuilder   // что достать
{
    public function monthly(): Collection { /* ... */ }
}

class CsvFormatter              // как показать
{
    public function format(Collection $data): string { /* ... */ }
}

class ReportSaver               // куда положить
{
    public function save(string $content): string { /* ... */ }
}

O — Open/Closed: открыт для расширения, закрыт для изменения

Принцип: новое поведение добавляем новым кодом, а не правкой существующего.

php
// ПЛОХО: каждый новый способ доставки = правка switch
class ShippingCostCalculator
{
    public function calculate(Order $order): float
    {
        switch ($order->shippingType) {
            case 'courier':  return 300.0;
            case 'post':     return 150.0;
            case 'pickup':   return 0.0;
            // добавили СПСР — правим этот метод, трогаем протестированный код
        }
    }
}
php
// ХОРОШО: новая доставка = новый класс, старый код не трогаем
interface ShippingMethod
{
    public function cost(Order $order): float;
}

final class CourierShipping implements ShippingMethod
{
    public function cost(Order $order): float { return 300.0; }
}

final class PostShipping implements ShippingMethod
{
    public function cost(Order $order): float { return 150.0; }
}

class ShippingCostCalculator
{
    public function __construct(
        /** @var iterable<ShippingMethod> */
        private iterable $methods,   // Laravel отдаёт тегged-коллекцию
    ) {}

    public function calculate(Order $order): float
    {
        foreach ($this->methods as $method) {
            if ($method->supports($order)) {
                return $method->cost($order);
            }
        }
        throw new \LogicException('Нет способа доставки');
    }
}

L — Liskov Substitution: наследник не ломает родителя

Принцип: где работает родитель — работает и наследник, без сюрпризов.

php
// ПЛОХО: наследник нарушает контракт родителя
class Bird
{
    public function fly(): void { /* ... */ }
}

class Penguin extends Bird
{
    public function fly(): void
    {
        throw new \LogicException('Пингвины не летают'); // код, ждущий Bird, падает
    }
}
php
// ХОРОШО: иерархия построена по возможностям, а не по «похожести»
interface Bird
{
    public function eat(): void;
}

interface CanFly
{
    public function fly(): void;
}

class Sparrow implements Bird, CanFly
{
    public function eat(): void {}
    public function fly(): void {}
}

class Penguin implements Bird   // умеет то, что умеет, — и не обещает лишнего
{
    public function eat(): void {}
}

Классический реальный пример: class Square extends Rectangle с квадратом, который ломает setWidth/setHeight — наследник переопределил поведение так, что код родителя перестал работать.

I — Interface Segregation: узкие интерфейсы

Принцип: клиент не должен зависеть от методов, которые ему не нужны.

php
// ПЛОХО: «толстый» интерфейс — все обязаны реализовать всё
interface Report
{
    public function toPdf(): string;
    public function toHtml(): string;
    public function email(): void;
}

class SalesReport implements Report
{
    public function toPdf(): string { /* ... */ }
    public function toHtml(): string { return ''; }   // пустышка — интерфейс навязал
    public function email(): void {}                  // пустышка — интерфейс навязал
}
php
// ХОРОШО: узкие интерфейсы — реализуем только нужное
interface PdfExportable    { public function toPdf(): string; }
interface HtmlExportable   { public function toHtml(): string; }
interface Emailable        { public function email(): void; }

class SalesReport implements PdfExportable, Emailable
{
    public function toPdf(): string { /* ... */ }
    public function email(): void { /* ... */ }
}

D — Dependency Inversion: зависимость от абстракций

Принцип: верхний код (бизнес-логика) не должен зависеть от деталей (Stripe, Redis, SMTP) — оба зависят от контракта.

php
// ПЛОХО: логика сцеплена с деталью — сменить или протестировать нельзя
class CheckoutService
{
    public function pay(Order $order): void
    {
        $stripe = new StripeGateway(config('services.stripe.key')); // «new» вшивает деталь
        $stripe->charge($order->total);
    }
}
php
// ХОРОШО: логика зависит от контракта, деталь вешается снаружи
interface PaymentGateway
{
    public function charge(float $amount): PaymentResult;
}

class CheckoutService
{
    public function __construct(private PaymentGateway $gateway) {}

    public function pay(Order $order): void
    {
        $this->gateway->charge($order->total);
    }
}

// Привязка детали — в одном месте
$this->app->bind(PaymentGateway::class, StripeGateway::class);

DIP — принцип, на котором стоит весь Laravel: контейнер, DI, фасады, моки в тестах — всё это следствие.

SOLID в Laravel

Принципы в Laravel спрятаны в привычные приёмы:

  • SRP — вынос логики из контроллера в сервис-класс, Form Request вместо валидации в контроллере, Job вместо отправки писем в контроллере.
  • OCP — тегged bindings и форм contracts: новая реализация интерфейса без правки потребителей.
  • LSP — SoftDeletes не ломает контракт модели: любая модель с трейтом работает там, где ждут Model.
  • ISP — контракты Laravel узкие по замыслу: Queue, Cache, Storage — каждый для своей задачи.
  • DIP — инъекция через конструктор, контракт вместо конкретного класса, PaymentGateway вместо StripeGateway.

Плохо / Хорошо

ПринципПлохоХорошо
SRPКласс-монстр: скидка + письмо + логКласс на одну причину изменения
OCPswitch по типу на каждый новый вариантРеализация интерфейса вместо правки кода
LSPНаследник кидает исключение там, где родитель работалИерархия по возможностям, не по «похожести»
ISPИнтерфейс на 10 методов с пустышкамиУзкие интерфейсы под конкретный клиент
DIPnew StripeGateway() внутри логикиКонтракт в конструкторе, привязка в провайдере

Подводный камень

  1. SOLID как религия. Класс на один метод, разнесённый на четыре «принципиальных» класса — это не архитектура, а раздутое дно. SOLID — инструмент, а не самоцель: на маленьком коде принципы иногда мешают.
  2. SRP ≠ «один метод». Ответственность — не «одна операция», а «одна причина для изменения». UserService может регистрировать, подтверждать и удалять пользователя — это всё про его ответственность, пока правки идут по одной причине.
  3. Репозиторий ради DIP без реального запроса. Интерфейс + реализация + мок на таблицу, которая никогда не меняется и не мокается — слой ради слоя. DIP окупается, когда реально есть вторая реализация или тестовая подмена.
  4. LSP ломается «тихо». Наследник не упадёт на месте — упадёт код, который доверял контракту родителя: очередь, тест, другой разработчик. Это самый коварный из пяти: нарушение видно не там, где написано.

Мини-шпаргалка

php
// SRP: одна причина изменения
class DiscountCalculator { /* только скидки */ }

// OCP: расширяем, не правим
interface ShippingMethod { public function cost(Order $o): float; }

// LSP: наследник везде заменяет родителя
class Sparrow implements Bird, CanFly { /* ... */ }
class Penguin implements Bird { /* без fly */ }

// ISP: узкие интерфейсы
interface PdfExportable { public function toPdf(): string; }

// DIP: контракт в конструкторе, деталь — снаружи
public function __construct(private PaymentGateway $gateway) {}

Три вывода

  1. SOLID — пять ответов на пять болей: «класс всё делает», «каждая фича правит старое», «наследник ломает родителя», «интерфейс навязывает лишнее», «код прилип к реализации».
  2. Главный практический эффект — тестируемость: класс с зависимостями по контрактам мокается за минуту, класс-монстр — только целиком и с реальной базой.
  3. Принципы работают вместе: DIP даёт заменяемость, OCP — расширяемость без правок, SRP — маленькие понятные классы; но у каждого оверкилл стоит дороже пользы.

Вопрос-ловушка

Спрашивают так: «Объясните SOLID». В джуниорской версии ждут расшифровку аббревиатуры — и большинство на этом останавливается. Отличаются те, кто на каждую букву даёт пример боли: не «SRP — принцип единственной ответственности», а «SRP — это когда класс считает скидку и шлёт письмо, и его невозможно протестировать». Хитрый поворот: «А нарушать SOLID можно?» Ожидаемый ответ — да, осознанно: маленький прототип с разросшимся классом лучше раздутое дно из «правильных» абстракций; принципы — инструмент для удержания сложности, а не чек-лист для код-ревью. Кандидат, который начинает с «нарушать нельзя», — читал статьи, но не строил.