Патерни проєктування й SOLID
20 питань · ~20 хв · Версія v3.0
Увійдіть, щоб продовжити
Принципи SOLID на прикладах, породжувальні, структурні й поведінкові патерни, їх реалізація в PHP і Laravel - питання від middle до lead.
- За спробу
- 20
- У пулі
- 57
- Проходжень
- 0
- Середній бал
- -
- Пройшли на 70%+
- -
Питання для підготовки
16 питань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 окупиться.
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»;
- клас важко назвати одним іменником;
- методи класу використовують різні, непересічні групи властивостей;
- зміна в одній функції регулярно ламає тести іншої.
Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.
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 уже є фабрикою для більшості сервісів: автовпровадження й прив'язки часто замінюють ручні фабрики.
Правило: патерн має прибирати складність, яку ви вже маєте, а не складність, що «може з'явитися». Ввести фабрику, коли з'явився другий варіант, легше, ніж підтримувати абстракцію, якої ніколи не знадобилося.
Принцип розділення інтерфейсів (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- вузькі контракти, які клас реалізує за потреби.
Межа здорового глузду: інтерфейс з одним методом на кожен метод класу - теж крайність. Групувати варто за тим, як інтерфейс використовують клієнти: методи, які завжди потрібні разом, - в одному інтерфейсі.
Спершу почитати
Прочитати - ще не значить знати
20 питань, по одному на екран, ~20 хв. Після завершення - розбір кожної помилки з посиланням на питання.