Junior: питання на співбесіді з теми «Патерни й SOLID»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
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для слухачів у черзі.
Коли не варто: якщо дія - обов'язкова частина сценарію (без неї операція неуспішна), її краще викликати явно, а не ховати в слухачі.