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

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»;
  • клас важко назвати одним іменником;
  • методи класу використовують різні, непересічні групи властивостей;
  • зміна в одній функції регулярно ламає тести іншої.

Пастка - надмірне дроблення. «Одна відповідальність» не означає «один метод». Десять класів по три рядки, які завжди змінюються разом, - теж погано: логіку однієї зміни доводиться збирати по багатьох файлах. Розділяють те, що змінюється з різних причин і в різний час.

Докладніше в документації: Single-responsibility principle

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 часто чесніший за патерн.

Докладніше в документації: Strategy

Принцип відкритості/закритості (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 для слухачів у черзі.

Коли не варто: якщо дія - обов'язкова частина сценарію (без неї операція неуспішна), її краще викликати явно, а не ховати в слухачі.

Докладніше в документації: Refactoring.Guru: Observer