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

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 уже є фабрикою для більшості сервісів: автовпровадження й прив'язки часто замінюють ручні фабрики.

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

Докладніше в документації: 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