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