Питання на співбесіді: Патерни й 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»;
- клас важко назвати одним іменником;
- методи класу використовують різні, непересічні групи властивостей;
- зміна в одній функції регулярно ламає тести іншої.
Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.
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 часто чесніший за патерн.
Принцип відкритості/закритості (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для слухачів у черзі.
Коли не варто: якщо дія - обов'язкова частина сценарію (без неї операція неуспішна), її краще викликати явно, а не ховати в слухачі.
Три схожі назви - три різні речі:
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) - принцип проєктування:
- Модулі високого рівня (бізнес-логіка) не мають залежати від модулів низького рівня (база, пошта, HTTP). Обидва залежать від абстракцій.
- Абстракції не залежать від деталей - деталі залежать від абстракцій.
«Інверсія» в тому, що інтерфейс належить бізнес-логіці й описаний її мовою, а інфраструктура його реалізує. Не «у нас є Stripe, давайте загорнемо його API в інтерфейс», а «бізнес-логіці потрібно PaymentGateway::charge(), а Stripe - одна з реалізацій».
IoC-контейнер (Service Container у Laravel) - інструмент, який автоматизує DI: сам створює об'єкти й підставляє залежності.
Зв'язок: DI можна робити без DIP - впроваджувати конкретний клас StripeClient. Тоді залежність ззовні, але бізнес-логіка все одно прив'язана до деталі. DIP додає абстракцію, а DI - спосіб її доставити.
Коли DIP виправданий: межі з зовнішнім світом, які справді можуть змінитися чи які треба підміняти в тестах - платежі, пошта, сторонні API, сховища. Інтерфейс на кожен клас «про всяк випадок» лише додає файлів і непрямих викликів без користі.
Усі три загортають інші об'єкти, але з різною метою:
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 - той самий інтерфейс, але контролює доступ до об'єкта: ліниве створення, перевірка прав, віддалений виклик.
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()). Але залежність через фасад так само прихована в коді методу, тож для бізнес-класів явне впровадження через конструктор зазвичай читабельніше.
Успадкування повторно використовує код через ієрархію «є різновидом» (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» доставка можуть виконати її двічі.
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 уже є фабрикою для більшості сервісів: автовпровадження й прив'язки часто замінюють ручні фабрики.
Правило: патерн має прибирати складність, яку ви вже маєте, а не складність, що «може з'явитися». Ввести фабрику, коли з'явився другий варіант, легше, ніж підтримувати абстракцію, якої ніколи не знадобилося.
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 частково допомагає: перевизначений метод не може звузити типи параметрів чи розширити тип повернення (контраваріантність і коваріантність перевіряються). Але поведінковий контракт рушій не перевіряє - лише тести й увага.
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: стани як класи, збережені в колонці моделі, з описаними переходами й власними класами-переходами для логіки.