Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Патерни й SOLID

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

15 питань

SRP - перша літера SOLID: у класу має бути одна причина для змін. Роберт Мартін формулює це так: модуль має відповідати перед одним «актором» - однією групою людей, чиї вимоги можуть його змінити.

Порушення:

class InvoiceService
{
    public function create(Order $order): Invoice { /* розрахунок сум, податків */ }
    public function renderPdf(Invoice $invoice): string { /* верстка */ }
    public function send(Invoice $invoice): void { /* SMTP, шаблон листа */ }
}

У класу три причини змінюватися: бухгалтерія змінює правила податків, дизайнер - вигляд PDF, маркетинг - текст листа. Зміна одного може зламати інше, а тест розрахунку змушує налаштовувати пошту.

Після розділення:

class InvoiceCalculator { public function create(Order $order): Invoice {} }
class InvoicePdf        { public function render(Invoice $invoice): string {} }
class InvoiceMailer     { public function send(Invoice $invoice): void {} }

Як розпізнати порушення:

  • назва класу з «And», «Manager», «Helper», «Util»;
  • клас важко назвати одним іменником;
  • методи класу використовують різні, непересічні групи властивостей;
  • зміна в одній функції регулярно ламає тести іншої.

Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.

Докладніше в документації: Single-responsibility principle

Strategy - кілька взаємозамінних алгоритмів за спільним інтерфейсом. Код, що їх використовує, не знає, який саме алгоритм працює, і не містить розгалужень за типом.

interface ShippingCalculator
{
    public function cost(Order $order): Money;
}

final class NovaPoshtaShipping implements ShippingCalculator { /* ... */ }
final class UkrposhtaShipping implements ShippingCalculator { /* ... */ }
final class PickupShipping implements ShippingCalculator { /* ... */ }

final class Checkout
{
    public function __construct(private ShippingCalculator $shipping) {}

    public function total(Order $order): Money
    {
        return $order->subtotal()->add($this->shipping->cost($order));
    }
}

Що це дає:

  • новий спосіб доставки - новий клас, без зміни Checkout (принцип відкритості/закритості);
  • кожну стратегію легко протестувати окремо;
  • немає switch ($order->shipping_method), що розростається з кожним новим варіантом.

У Laravel цей патерн скрізь - під назвою «драйвери»:

  • кеш: file, redis, database за контрактом Store;
  • файлові диски: локальний, S3, FTP за спільним API Storage;
  • черги, пошта, логи, сесії, хешування - той самий принцип.

Конкретна стратегія обирається конфігурацією (CACHE_STORE=redis), а код працює з контрактом.

Як обирати стратегію під час виконання: через контейнер (прив'язати інтерфейс до реалізації), фабрику чи мапу [метод => клас]. Якщо варіантів два й вони ніколи не множитимуться, простий if часто чесніший за патерн.

Докладніше в документації: Strategy

Принцип відкритості/закритості (Open/Closed Principle, «O» в SOLID): модуль має бути відкритим для розширення і закритим для змін. Нову поведінку додають новим кодом, а не переписуванням того, що вже працює й протестоване.

Порушення - кожна нова можливість змінює той самий код:

class ShippingCalculator
{
    public function cost(Order $order, string $carrier): int
    {
        return match ($carrier) {
            'nova_poshta' => 70 + $order->weight * 5,
            'ukrposhta' => 45 + $order->weight * 3,
            // новий перевізник - правка цього класу, ризик зламати наявних
        };
    }
}

Відповідно до OCP - розширення через новий клас:

interface ShippingMethod
{
    public function cost(Order $order): int;
}

final class NovaPoshta implements ShippingMethod { /* ... */ }
final class Ukrposhta implements ShippingMethod { /* ... */ }
final class Meest implements ShippingMethod { /* ... */ }   // новий - без змін в інших

class ShippingCalculator
{
    public function cost(Order $order, ShippingMethod $method): int
    {
        return $method->cost($order);
    }
}

Типові механізми розширення:

  • поліморфізм через інтерфейси (Strategy);
  • події: нова реакція на «замовлення оплачено» - новий слухач, а не правка контролера;
  • декоратори й middleware - додати поведінку навколо наявної;
  • конфігурація й реєстрація: нова команда, драйвер, канал сповіщень підключається в сервіс-провайдері.

Як це виглядає в Laravel: нові драйвери кешу, черг, файлових систем (Storage::extend()), канали сповіщень, правила валідації, макроси - фреймворк закритий для змін, але відкритий для розширення.

Застереження - не передбачати все наперед. OCP не означає «робити інтерфейс на кожен клас про всяк випадок». Розширювані точки варто вводити, коли з'являється друга реалізація чи явна потреба (правило трьох: втретє однакова зміна - час для абстракції). Передчасна гнучкість - це складність, яка може ніколи не знадобитися.

Корисний тест: чи доводилося останні кілька разів змінювати той самий match/switch, додаючи варіант? Якщо так - це місце, де OCP окупиться.

Докладніше в документації: Принцип відкритості/закритості

Принцип розділення інтерфейсів (Interface Segregation Principle, «I» в SOLID): клієнти не повинні залежати від методів, якими вони не користуються. Краще кілька вузьких інтерфейсів, ніж один «товстий».

Порушення:

interface Storage
{
    public function read(string $path): string;
    public function write(string $path, string $contents): void;
    public function delete(string $path): void;
    public function temporaryUrl(string $path, DateTimeInterface $expires): string;
    public function listContents(string $dir): array;
}

Клас, якому потрібно лише читати файли, залежить від усього інтерфейсу. Реалізація для сховища, де немає тимчасових посилань, змушена кидати NotSupportedException - а це вже порушення й принципу Лісков.

Відповідно до ISP:

interface ReadsFiles
{
    public function read(string $path): string;
}

interface WritesFiles
{
    public function write(string $path, string $contents): void;
    public function delete(string $path): void;
}

interface GeneratesTemporaryUrls
{
    public function temporaryUrl(string $path, DateTimeInterface $expires): string;
}

final class ReportGenerator
{
    public function __construct(private ReadsFiles $files) {}   // лише те, що потрібно
}

Один клас може реалізовувати кілька інтерфейсів, а кожен споживач залежить лише від потрібного.

Навіщо:

  • простіші тести: фейк для ReadsFiles - один метод, а не п'ять заглушок;
  • менше зв'язності: зміна методу запису не зачіпає класи, що лише читають;
  • чесні реалізації: немає методів, що кидають «не підтримується»;
  • зрозумілі залежності: з конструктора видно, що саме клас використовує.

Приклади в PHP і Laravel:

  • PHP: Countable, IteratorAggregate, ArrayAccess, JsonSerializable, Stringable - маленькі інтерфейси, кожен про одну можливість;
  • Laravel: ShouldQueue, ShouldBroadcast, HasLocalePreference, Arrayable, Jsonable, Responsable - вузькі контракти, які клас реалізує за потреби.

Межа здорового глузду: інтерфейс з одним методом на кожен метод класу - теж крайність. Групувати варто за тим, як інтерфейс використовують клієнти: методи, які завжди потрібні разом, - в одному інтерфейсі.

Докладніше в документації: Принцип розділення інтерфейсів

Observer (Спостерігач) - об'єкт-«видавець» повідомляє про зміну стану всіх зацікавлених «підписників», не знаючи про них нічого конкретного. Підписники реєструються самі.

Проблема, яку він розв'язує:

public function pay(Order $order): void
{
    $order->markAsPaid();
    $this->mailer->sendReceipt($order);
    $this->warehouse->reserve($order);
    $this->analytics->trackPurchase($order);
    $this->crm->updateCustomer($order->customer);
    // кожна нова реакція - правка цього методу
}

Оплата «знає» про пошту, склад, аналітику й CRM. Зміни в будь-якій з цих систем зачіпають код оплати.

З Observer (у Laravel - події й слухачі):

public function pay(Order $order): void
{
    $order->markAsPaid();
    OrderPaid::dispatch($order);
}
class SendReceipt { public function handle(OrderPaid $event): void { /* ... */ } }
class ReserveStock { public function handle(OrderPaid $event): void { /* ... */ } }

Laravel знаходить слухачів автоматично за типом події в методі handle. Нова реакція - новий клас-слухач, код оплати не змінюється.

Інші реалізації Observer у Laravel:

  • спостерігачі моделей (#[ObservedBy(OrderObserver::class)]) - реакція на created, updated, deleted;
  • події моделей (static::created(...));
  • трансляція подій у браузер (ShouldBroadcast) - підписники навіть в іншому процесі;
  • Livewire #[On] і події JavaScript - та сама ідея на клієнті.

Переваги: слабка зв'язність, легко додавати реакції, слухачі можна виконувати в черзі (ShouldQueue).

Недоліки й пастки:

  • неявний потік: з коду OrderPaid::dispatch() не видно, що станеться далі - треба шукати слухачів (php artisan event:list);
  • порядок виконання слухачів не варто вважати гарантованим для бізнес-логіки;
  • спостерігачі моделей спрацьовують на кожне збереження, включно з сидерами, імпортом, тестами - і не спрацьовують на масові запити (Order::where(...)->update());
  • транзакції: подія, оброблена до коміту, може відправити лист про замовлення, яке потім відкотиться - ShouldDispatchAfterCommit чи afterCommit для слухачів у черзі.

Коли не варто: якщо дія - обов'язкова частина сценарію (без неї операція неуспішна), її краще викликати явно, а не ховати в слухачі.

Докладніше в документації: Refactoring.Guru: Observer

Три схожі назви - три різні речі:

Dependency Injection (впровадження залежностей) - техніка: об'єкт отримує залежності ззовні (через конструктор), а не створює їх сам.

// Без DI
class ReportService { private $mailer; public function __construct() { $this->mailer = new SmtpMailer(); } }

// З DI
class ReportService { public function __construct(private Mailer $mailer) {} }

Dependency Inversion Principle (DIP, «D» у SOLID) - принцип проєктування:

  1. Модулі високого рівня (бізнес-логіка) не мають залежати від модулів низького рівня (база, пошта, HTTP). Обидва залежать від абстракцій.
  2. Абстракції не залежать від деталей - деталі залежать від абстракцій.

«Інверсія» в тому, що інтерфейс належить бізнес-логіці й описаний її мовою, а інфраструктура його реалізує. Не «у нас є Stripe, давайте загорнемо його API в інтерфейс», а «бізнес-логіці потрібно PaymentGateway::charge(), а Stripe - одна з реалізацій».

IoC-контейнер (Service Container у Laravel) - інструмент, який автоматизує DI: сам створює об'єкти й підставляє залежності.

Зв'язок: DI можна робити без DIP - впроваджувати конкретний клас StripeClient. Тоді залежність ззовні, але бізнес-логіка все одно прив'язана до деталі. DIP додає абстракцію, а DI - спосіб її доставити.

Коли DIP виправданий: межі з зовнішнім світом, які справді можуть змінитися чи які треба підміняти в тестах - платежі, пошта, сторонні API, сховища. Інтерфейс на кожен клас «про всяк випадок» лише додає файлів і непрямих викликів без користі.

Докладніше в документації: Dependency inversion principle

Усі три загортають інші об'єкти, але з різною метою:

Adapter - змінює інтерфейс: приводить чужий об'єкт до інтерфейсу, який очікує ваш код.

final class TwilioSmsAdapter implements SmsSender      // ваш інтерфейс
{
    public function __construct(private TwilioClient $twilio) {}

    public function send(PhoneNumber $to, string $text): void
    {
        $this->twilio->messages->create((string) $to, ['body' => $text, 'from' => '...']);
    }
}

Decorator - той самий інтерфейс, додана поведінка. Загортає об'єкт і додає щось до або після виклику; декоратори можна нашаровувати.

final class CachedExchangeRates implements ExchangeRates
{
    public function __construct(private ExchangeRates $inner, private CacheRepository $cache) {}

    public function rate(string $from, string $to): float
    {
        return $this->cache->remember("rate:$from:$to", 3600, fn () => $this->inner->rate($from, $to));
    }
}

Кешування, логування, повтори, метрики - типові декоратори. Обгорнутий і обгортка взаємозамінні.

Facade (класичний, GoF) - спрощений інтерфейс до складної підсистеми: один клас з кількома простими методами замість десятка класів з тонкими налаштуваннями.

Фасади Laravel (Cache::, DB::) - інше: це статичний доступ до сервісів з контейнера (ближче до Service Locator). Назва схожа, але патерн GoF не про це.

Як розрізнити на співбесіді:

  • інтерфейс змінюється - Adapter;
  • інтерфейс той самий, поведінки більше - Decorator;
  • інтерфейс простіший за те, що за ним, - Facade.

Ще схожий Proxy - той самий інтерфейс, але контролює доступ до об'єкта: ліниве створення, перевірка прав, віддалений виклик.

Докладніше в документації: Adapter

Singleton гарантує, що клас має один екземпляр, і дає до нього глобальний доступ:

final class Config
{
    private static ?self $instance = null;

    public static function getInstance(): self
    {
        return self::$instance ??= new self();
    }

    private function __construct() {}
}

Config::getInstance()->get('app.name');

Чому його вважають антипатерном (точніше - класичну реалізацію):

  • прихована залежність: клас, що десь усередині викликає Config::getInstance(), з конструктора не показує, що залежить від конфігурації;
  • глобальний стан: будь-який код може змінити спільний об'єкт - важко відстежити, хто й коли;
  • тестування: статичний екземпляр не підміниш фейком без хитрощів, а стан «перетікає» між тестами;
  • порушення SRP: клас відповідає і за свою роботу, і за керування власним життєвим циклом;
  • довгоживучі процеси (Octane, черги): статичний стан живе між запитами - дані одного користувача можуть потрапити до іншого.

Задача «один екземпляр на застосунок» справжня - з'єднання з базою, клієнт API, конфігурація. Розв'язувати її краще контейнером:

// AppServiceProvider
$this->app->singleton(PaymentGateway::class, fn () => new StripeGateway(config('services.stripe.secret')));

// використання - звичайна залежність
final class CheckoutService
{
    public function __construct(private PaymentGateway $payments) {}
}
  • один екземпляр - контейнер створює об'єкт один раз і повертає його всім;
  • явна залежність у конструкторі;
  • підміна в тестах: $this->app->instance(PaymentGateway::class, new FakeGateway) чи swap;
  • клас нічого не знає про те, що він «єдиний» - це рішення конфігурації, а не самого класу.

scoped() замість singleton() для Octane: екземпляр живе в межах одного запиту чи задачі в черзі і скидається між ними - захист від витоку стану між користувачами.

Фасади Laravel - не Singleton у класичному сенсі: це статичний доступ до об'єкта з контейнера, який можна підмінити (Cache::fake(), Mail::fake()). Але залежність через фасад так само прихована в коді методу, тож для бізнес-класів явне впровадження через конструктор зазвичай читабельніше.

Докладніше в документації: Refactoring.Guru: Singleton

Успадкування повторно використовує код через ієрархію «є різновидом» (Admin extends User). Композиція - через «має» чи «використовує»: об'єкт отримує інші об'єкти й делегує їм роботу.

Проблеми глибокого успадкування:

class Report { public function generate() { /* ... */ } }
class PdfReport extends Report { /* ... */ }
class CachedPdfReport extends PdfReport { /* ... */ }
class CachedEmailedPdfReport extends CachedPdfReport { /* ... */ }
// а потрібен ще CachedEmailedCsvReport...
  • комбінаторний вибух: кожна комбінація можливостей - новий клас;
  • крихкий базовий клас: зміна в Report непередбачувано ламає нащадків;
  • жорсткий зв'язок: нащадок залежить від внутрішніх деталей батька (protected), а не лише від публічного API;
  • одне успадкування в PHP: клас не може взяти поведінку з двох батьків.

Композицією:

final class ReportGenerator
{
    public function __construct(
        private ReportFormatter $formatter,     // Pdf чи Csv
        private ReportDelivery $delivery,       // Email чи Storage
        private CacheRepository $cache,
    ) {}

    public function run(ReportQuery $query): void
    {
        $data = $this->cache->remember($query->key(), 3600, fn () => $query->fetch());
        $this->delivery->send($this->formatter->format($data));
    }
}

Будь-яка комбінація - різні об'єкти в конструкторі, без нових класів. Поведінку можна змінити під час виконання й легко підмінити в тестах.

Інструменти композиції: впровадження залежностей, Strategy, Decorator (обгортання з тим самим інтерфейсом), делегування.

Трейти PHP - механізм повторного використання коду без успадкування, але це радше «копіювання методів» у клас: вони не створюють окремих об'єктів і можуть мати ті самі проблеми прихованих залежностей. Добрі для невеликої поведінки (HasFactory, SoftDeletes), погані як заміна архітектури.

Коли успадкування доречне:

  • справжнє відношення «є різновидом», де нащадок повністю замінює батька (принцип Лісков);
  • точки розширення фреймворку: extends Model, extends Controller, extends Command - фреймворк так спроєктований;
  • абстрактний клас з невеликою спільною логікою і шаблонним методом;
  • неглибока ієрархія (1-2 рівні).

Корисне правило: класи за замовчуванням final. Відкривати для успадкування - свідоме рішення, а не випадковість.

Докладніше в документації: Композиція замість успадкування

Command (Команда) - запит на дію оформлюється окремим об'єктом, що містить усе потрібне для виконання: що зробити і з якими даними. Тоді дію можна передати, відкласти, поставити в чергу, повторити, записати в журнал чи скасувати.

Laravel-джоба - класичний Command:

final class GenerateInvoicePdf implements ShouldQueue
{
    use Queueable;

    public function __construct(public Invoice $invoice) {}

    public function handle(PdfRenderer $renderer): void
    {
        $this->invoice->update(['pdf_path' => $renderer->render($this->invoice)]);
    }
}

GenerateInvoicePdf::dispatch($invoice);           // виконати пізніше в черзі
GenerateInvoicePdf::dispatchSync($invoice);       // виконати зараз
  • об'єкт-команда містить дані ($invoice) і знає, що робити (handle);
  • виконавець (воркер черги) не знає деталей - просто викликає handle;
  • відправник не знає, коли й де команда виконається.

Що дає такий підхід:

  • відкладене й асинхронне виконання - черги;
  • повтори при збоях ($tries, backoff) - команда серіалізується й виконується знову;
  • ланцюжки й пакети: Bus::chain([...]), Bus::batch([...]) - послідовність команд як дані;
  • журнал: кожна команда - запис у черзі з параметрами, спробами, помилками (Horizon);
  • middleware для команд (RateLimited, WithoutOverlapping) - спільна поведінка навколо будь-якої команди.

Інші Command у Laravel: Artisan-команди, дії (action-класи з одним методом handle/__invoke), Pipeline з об'єктами-кроками.

Command + скасування (класичний варіант патерну): команда має execute() і undo() - основа функцій «Скасувати» в редакторах. У вебзастосунках частіше роблять компенсуючі дії (повернення коштів, а не «відкат» оплати).

CQRS-підхід з шиною команд: контролер створює команду PlaceOrder, шина знаходить обробник PlaceOrderHandler. Це розділяє «що потрібно зробити» і «як», але додає шар непрямості - доречно у великих доменах, а в звичайному Laravel-застосунку часто досить action-класів.

Важливо для черг: команда має бути ідемпотентною або захищеною від повторного виконання (ShouldBeUnique, перевірка стану) - повтори при збоях і «at least once» доставка можуть виконати її двічі.

Докладніше в документації: Refactoring.Guru: Command

Factory (фабричний метод, фабрика) ховає рішення, який саме об'єкт створити, і як.

Доречна, коли:

  • конкретний клас залежить від даних під час виконання: NotificationChannelFactory::for($user->preferred_channel);
  • створення потребує залежностей, яких не має код-споживач (клієнт API з налаштуваннями, токеном, ретраями);
  • створення - нетривіальна послідовність, яку не хочеться повторювати.

Іменовані конструктори (Money::fromCents(1999), Period::lastMonth()) - найлегша форма фабрики, яка часто краща за складний конструктор.

Builder збирає складний об'єкт покроково, коли параметрів багато й більшість необов'язкові:

$query = User::query()
    ->where('active', true)
    ->whereHas('orders')
    ->orderBy('name')
    ->limit(20);

$mail = (new MailMessage)
    ->subject('Рахунок')
    ->line('Ваш рахунок готовий.')
    ->action('Переглянути', $url);

Доречний, коли конструктор з десятьма параметрами нечитабельний, частина з них взаємозалежна, а об'єкт треба валідувати як ціле перед створенням. У Laravel так влаштовані Query Builder, MailMessage, Http::withHeaders()->retry()->get().

Коли це зайве:

  • фабрика, що просто викликає new без жодного рішення, - додатковий файл без користі;
  • Builder для об'єкта з трьома обов'язковими полями - іменовані аргументи PHP 8 вирішують ту саму задачу: new Invoice(number: ..., total: ..., dueDate: ...);
  • контейнер Laravel уже є фабрикою для більшості сервісів: автовпровадження й прив'язки часто замінюють ручні фабрики.

Правило: патерн має прибирати складність, яку ви вже маєте, а не складність, що «може з'явитися». Ввести фабрику, коли з'явився другий варіант, легше, ніж підтримувати абстракцію, якої ніколи не знадобилося.

Докладніше в документації: Builder

LSP («L» у SOLID): об'єкт підтипу має бути можливо підставити скрізь, де очікується базовий тип, без зміни правильності програми. Нащадок має виконувати контракт предка, а не лише мати ті самі методи.

Класичне порушення - квадрат і прямокутник:

class Rectangle
{
    public function setWidth(int $w): void { $this->width = $w; }
    public function setHeight(int $h): void { $this->height = $h; }
    public function area(): int { return $this->width * $this->height; }
}

class Square extends Rectangle
{
    public function setWidth(int $w): void { $this->width = $this->height = $w; }
    public function setHeight(int $h): void { $this->width = $this->height = $h; }
}

function stretch(Rectangle $r): void
{
    $r->setWidth(5);
    $r->setHeight(2);
    assert($r->area() === 10);   // для Square - 4
}

Математично квадрат - прямокутник, але поведінково ні: незалежна зміна сторін - частина контракту Rectangle.

Як порушують у реальному коді:

  • Метод нащадка кидає NotImplementedException чи нічого не робить: ReadOnlyRepository extends Repository з save(), що кидає виняток.
  • Посилені вимоги до вхідних даних: предок приймає будь-який рядок, нащадок - лише непорожній.
  • Послаблені гарантії результату: предок завжди повертає об'єкт, нащадок - інколи null.
  • Нові винятки, яких клієнти базового типу не очікують і не ловлять.
  • Перевірка типу в коді клієнта: if ($shape instanceof Square) - ознака, що підстановка не працює.

Що робити: якщо нащадок не може виконати контракт, він не має бути нащадком. Розділити інтерфейси (ReadableRepository, WritableRepository - принцип розділення інтерфейсів), використати композицію або незмінні об'єкти (незмінний квадрат цілком може бути незмінним прямокутником).

PHP частково допомагає: перевизначений метод не може звузити типи параметрів чи розширити тип повернення (контраваріантність і коваріантність перевіряються). Але поведінковий контракт рушій не перевіряє - лише тести й увага.

Докладніше в документації: Liskov substitution principle

Chain of Responsibility (Ланцюжок обов'язків) - запит проходить послідовністю обробників. Кожен вирішує: обробити запит і зупинити ланцюжок, або щось зробити й передати далі.

Middleware Laravel - саме такий ланцюжок:

final class EnsureUserIsSubscribed
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->subscribed()) {
            return redirect()->route('billing');   // зупинити ланцюжок
        }

        $response = $next($request);               // передати далі

        $response->headers->set('X-Plan', $request->user()->plan);   // після обробки
        return $response;
    }
}
  • $next($request) - виклик наступного обробника; код до нього виконується на шляху запиту, після - на шляху відповіді;
  • повернення відповіді без $next - ланцюжок зупиняється (автентифікація, CSRF, обмеження частоти, режим обслуговування);
  • обробники не знають один про одного - лише про $next.

Під капотом - Illuminate\Pipeline\Pipeline, який можна використати й для власних процесів:

$order = Pipeline::send($order)
    ->through([
        ValidateStock::class,
        ApplyDiscounts::class,
        CalculateTaxes::class,
        ReserveItems::class,
    ])
    ->thenReturn();

Кожен крок - клас з методом handle($order, Closure $next). Кроки легко переставити, додати, протестувати окремо.

Переваги:

  • відокремлення відправника від обробників;
  • набір і порядок обробників - конфігурація, а не код (групи middleware, порядок ->through());
  • кожен обробник з однією відповідальністю.

Пастки:

  • порядок має значення: middleware автентифікації після middleware, що використовує користувача, - помилка. Laravel має $middleware->priority() для впорядкування;
  • ніхто не обробив: у класичному варіанті запит може пройти весь ланцюжок без обробки - потрібен обробник за замовчуванням;
  • налагодження: довгий ланцюжок з побічними ефектами важко відстежити - кожен крок має бути простим;
  • продуктивність: глобальні middleware виконуються на кожен запит, тож у них не місце важким операціям.

Інші приклади: обробники винятків, валідатори з кількома правилами, системи знижок, ланцюжки фільтрів запитів (пошук з фільтрами - кожен фільтр додає умову й передає далі).

Докладніше в документації: Refactoring.Guru: Chain of Responsibility

Обидва патерни розв'язують одну задачу - змінювати частину алгоритму, не змінюючи загальної структури. Різниця - у механізмі.

Template Method - через успадкування. Базовий клас задає скелет алгоритму, а змінні кроки - абстрактні методи для нащадків:

abstract class DataImport
{
    final public function run(string $path): ImportResult
    {
        $rows = $this->read($path);                   // змінний крок
        $valid = array_filter($rows, $this->validate(...));
        $this->persist($valid);                       // змінний крок
        return new ImportResult(count($valid), count($rows) - count($valid));
    }

    abstract protected function read(string $path): array;
    abstract protected function validate(array $row): bool;
    abstract protected function persist(array $rows): void;
}

final class ProductImport extends DataImport { /* реалізує три кроки */ }

Strategy - через композицію. Змінна частина - окремий об'єкт, переданий ззовні:

final class DataImport
{
    public function __construct(
        private RowReader $reader,
        private RowValidator $validator,
        private RowWriter $writer,
    ) {}

    public function run(string $path): ImportResult { /* той самий скелет */ }
}

Порівняння:

Template Method Strategy
механізм успадкування композиція
коли обирається варіант при створенні класу (компіляція) при створенні об'єкта (виконання)
комбінування варіантів новий підклас на кожну комбінацію будь-яка комбінація об'єктів
доступ до стану нащадок бачить protected батька лише через параметри й інтерфейс
тестування кроків через підклас кожна стратегія окремо

Коли Template Method доречний:

  • скелет стабільний, варіантів небагато, кроки тісно пов'язані між собою;
  • фреймворк визначає життєвий цикл, а ви заповнюєте «гачки»: Command::handle(), Seeder::run(), Notification::via()/toMail() у Laravel, setUp() у тестах - усе це шаблонні методи;
  • кроки потребують спільного стану базового класу.

Коли Strategy:

  • варіанти комбінуються незалежно (формат × доставка × кешування);
  • варіант обирається під час виконання (за налаштуваннями, типом користувача);
  • кроки корисно тестувати й повторно використовувати окремо.

Еволюція: часто код починається з Template Method (простіше), а коли комбінацій стає багато - рефакториться в Strategy («замінити успадкування делегуванням»). final на методі-скелеті в Template Method важливий - нащадки не повинні змінювати порядок кроків.

Докладніше в документації: Refactoring.Guru: Template Method

State (Стан) - поведінка об'єкта змінюється залежно від його стану, а кожен стан оформлений окремим класом. Замість умов у кожному методі об'єкт делегує поведінку поточному стану.

Без патерну - умови розповзаються по коду:

class Order
{
    public function cancel(): void
    {
        if ($this->status === 'shipped' || $this->status === 'delivered') {
            throw new CannotCancel();
        }
        if ($this->status === 'paid') {
            $this->refund();
        }
        $this->status = 'cancelled';
    }

    public function ship(): void
    {
        if ($this->status !== 'paid') { throw new CannotShip(); }
        // ...
    }
    // і так у кожному методі
}

Новий стан («очікує оплати частинами») - правка всіх методів, легко пропустити перевірку в одному з них.

З патерном State:

abstract class OrderState
{
    public function cancel(Order $order): void { throw new InvalidTransition(static::class, 'cancel'); }
    public function ship(Order $order): void { throw new InvalidTransition(static::class, 'ship'); }
}

final class Paid extends OrderState
{
    public function cancel(Order $order): void
    {
        $order->refund();
        $order->transitionTo(new Cancelled());
    }

    public function ship(Order $order): void
    {
        $order->transitionTo(new Shipped());
    }
}

final class Shipped extends OrderState { /* cancel не дозволено - поведінка за замовчуванням */ }

Переваги:

  • усі правила стану в одному класі: що дозволено зі стану «Оплачено», видно в класі Paid;
  • недозволені переходи - помилка за замовчуванням, а не забута перевірка;
  • новий стан - новий клас, наявні не змінюються (OCP);
  • легко тестувати кожен стан окремо.

Простіша альтернатива - енум зі списком переходів:

enum OrderStatus: string
{
    case Pending = 'pending';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

    public function canTransitionTo(self $next): bool
    {
        return in_array($next, match ($this) {
            self::Pending => [self::Paid, self::Cancelled],
            self::Paid => [self::Shipped, self::Cancelled],
            self::Shipped, self::Cancelled => [],
        }, true);
    }
}

Як обрати:

  • різниться лише набір дозволених переходів - енум з таблицею переходів;
  • різниться поведінка в кожному стані (різні побічні ефекти, розрахунки, доступні дії) - класи станів.

У Laravel готове рішення - spatie/laravel-model-states: стани як класи, збережені в колонці моделі, з описаними переходами й власними класами-переходами для логіки.

Докладніше в документації: Refactoring.Guru: State