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

Питання на співбесіді з Архітектура

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

100 питань

Лавина кешу (cache stampede, thundering herd) - популярний ключ кешу закінчується, і сотні одночасних запитів не знаходять значення, всі разом ідуть у базу й обчислюють одне й те саме. База, що спокійно працювала завдяки кешу, раптом отримує навантаження, від якого падає. А поки вона повільна, ключ ще довше не з'являється в кеші.

Захист:

1. Блокування - обчислює лише один.

$value = Cache::get('report');

if ($value === null) {
    $value = Cache::lock('report:lock', 10)->block(5, function () {
        return Cache::remember('report', now()->plus(minutes: 10), fn () => buildReport());
    });
}

Один процес обчислює, інші чекають і отримують готове значення.

2. Stale-while-revalidate - віддавати застаріле, оновлювати у фоні. У Laravel - Cache::flexible():

$stats = Cache::flexible('dashboard:stats', [300, 900], fn () => computeStats());
  • перші 300 секунд значення свіже - віддається з кешу;
  • з 300 до 900 секунд - застаріле, але прийнятне: віддається одразу, а перерахунок запускається після відправки відповіді (лише один, із блокуванням);
  • після 900 секунд - обчислюється заново синхронно.

Користувачі майже ніколи не чекають на обчислення, а база не отримує лавини.

3. Ймовірнісне дострокове оновлення - кожен запит з невеликою ймовірністю, що зростає ближче до кінця TTL, оновлює значення заздалегідь. Лише один-два запити підуть у базу до закінчення терміну.

4. Розкид TTL (jitter) - ключі, створені одночасно (наприклад, після деплою чи прогріву), отримують трохи різний термін - і не закінчуються в одну секунду.

5. Прогрів кешу - фонова задача оновлює важливі ключі за розкладом, і користувацькі запити кеш ніколи не «пропускають».

Схожа проблема - холодний старт: після очищення всього кешу (деплой, перезапуск Redis) - лавина по всіх ключах одразу. Тому cache:clear на продакшені під навантаженням - ризикована операція.

Докладніше в документації: Laravel: stale-while-revalidate

У більшості вебзастосунків читань у десятки разів більше, ніж записів. Репліки дають змогу розподілити читання між кількома серверами бази, а всі записи лишити на основному (primary).

У Laravel - розділення з'єднань:

'mysql' => [
    'read' => ['host' => ['10.0.0.11', '10.0.0.12']],
    'write' => ['host' => ['10.0.0.10']],
    'sticky' => true,
    // ...
],

SELECT ідуть на репліки, усе інше - на основний сервер.

Затримка реплікації - репліка отримує зміни з запізненням: зазвичай мілісекунди, під навантаженням чи при важких запитах - секунди й більше.

Типові наслідки:

  • «не бачу свого запису»: користувач зберіг профіль, сторінка після редиректу читає з репліки - і показує старі дані;
  • джоба в черзі не знаходить запис: модель щойно створено на основному сервері, воркер читає з репліки, де її ще немає, - ModelNotFoundException;
  • неправильні рішення: перевірка «чи вистачає залишку на складі» на застарілій репліці.

Як з цим жити:

  • sticky => true - якщо в поточному запиті був запис, наступні читання цього ж запиту йдуть на основний сервер;
  • читання з основного сервера там, де важлива свіжість: після запису, при перевірках перед зміною даних, у критичних джобах (->useWritePdo(), окреме з'єднання);
  • «читати свої записи» - кілька секунд після запису користувача направляти його читання на основний сервер (позначка в сесії);
  • моніторинг затримки реплікації зі сповіщеннями - і автоматичне виключення відсталої репліки з пулу.

Що репліки не вирішують:

  • масштабування записів - усі записи все одно на одному сервері;
  • повільні запити - запит, що займає 10 секунд, займе їх і на репліці. Спершу індекси й оптимізація.

Корисне застосування окремої репліки: важкі звіти, аналітика, бекапи - щоб вони не впливали на основний трафік.

Докладніше в документації: Laravel: з'єднання для читання й запису

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

Приклад: розпродаж - 5000 замовлень за хвилину. Генерація рахунків, листи, синхронізація зі складом і бухгалтерією не встигають. Якщо робити це в запиті - запити повільні, з'єднання з базою вичерпуються, сайт падає. Із чергою:

public function store(StoreOrderRequest $request)
{
    $order = Order::create($request->validated());

    ProcessOrder::dispatch($order);   // секунди роботи - у фоні

    return new OrderResource($order);   // відповідь за мілісекунди
}

Користувач отримує відповідь одразу, а обробка «розтягується» на кілька хвилин після піку.

Що дає черга:

  • швидкі відповіді - у запиті лише необхідне;
  • стійкість до збоїв - зовнішній сервіс недоступний, задачі чекають і повторюються;
  • незалежне масштабування - воркерів можна додати окремо від вебсерверів;
  • захист повільних систем - сторонній API з лімітом отримує запити з контрольованою швидкістю.

Зворотний тиск (backpressure) - механізм, що сповільнює виробника, коли споживач не встигає. Без нього черга росте необмежено: пам'ять Redis закінчується, задачі виконуються з годинною затримкою, і система падає пізніше, але гірше.

Способи зворотного тиску:

  • обмеження довжини черги - при переповненні відмовляти (503) чи сповільнювати прийом;
  • ліміти частоти на вході - не приймати більше роботи, ніж система може переробити;
  • пріоритетні черги - важливе (оплати) окремо від некритичного (аналітика), щоб масова дешева робота не блокувала важливу;
  • моніторинг глибини черги й часу очікування - Laravel Horizon показує обидва й може сповіщати (LongWaitDetected);
  • автомасштабування воркерів за довжиною черги.

Що варто пам'ятати: черга не прибирає роботу, лише переносить її в часі. Якщо середнє навантаження вище за пропускну здатність воркерів, черга росте вічно - потрібна більша потужність, а не довша черга.

Докладніше в документації: Azure Architecture: Queue-Based Load Leveling

Кожне з'єднання з базою коштує ресурсів на сервері бази: у PostgreSQL це окремий процес з власною пам'яттю (мегабайти), у MySQL - потік. Тому кількість з'єднань обмежена (max_connections), і її не можна просто підняти до тисяч - база витратить пам'ять і процесор на керування з'єднаннями замість запитів.

Як PHP-застосунок вичерпує з'єднання: кожен процес PHP-FPM (чи воркер черги, чи воркер Octane) тримає власне з'єднання.

4 вебсервери × 50 процесів PHP-FPM   = 200 з'єднань
3 сервери воркерів × 20 процесів      =  60
планувальник, Horizon, Pulse, cron     ~  20

Додали сервери під час піку - і наступний запит отримує too many connections. Причому з'єднань багато, а активних запитів у кожен момент - одиниці: більшість процесів PHP у цей час чекає на мережу, рендерить шаблон чи стоїть без діла.

Пул з'єднань - проміжний сервіс між застосунком і базою (PgBouncer для PostgreSQL, ProxySQL для MySQL, керовані пули в хмарах):

  • застосунок відкриває сотні «дешевих» з'єднань до пулу;
  • пул тримає невелику кількість справжніх з'єднань до бази (наприклад, 30) і видає їх на час транзакції;
  • база бачить рівно стільки з'єднань, скільки може ефективно обслужити.

Режими PgBouncer:

  • session - з'єднання закріплене за клієнтом на весь сеанс (мало виграшу);
  • transaction - з'єднання видається лише на час транзакції - найефективніший, але ламає функції, що живуть довше транзакції: SET на рівні сесії, advisory locks на сесію, LISTEN/NOTIFY, підготовлені запити поза протоколом;
  • statement - на кожен оператор, найобмеженіший.

Для Laravel з PgBouncer у режимі transaction - вимкнути емульовані/постійні підготовлені запити, якщо вони несумісні, і не покладатися на стан сесії між транзакціями.

Інші важелі:

  • обмежити кількість процесів PHP-FPM і воркерів реальною потребою;
  • закривати з'єднання в довгоживучих воркерах між задачами, якщо задачі рідкісні;
  • скоротити тривалість транзакцій - менше часу з'єднання зайняте.

Помилка, якої варто уникати: «вирішити» проблему, піднявши max_connections до тисяч - база почне деградувати від кількості з'єднань раніше, ніж від запитів.

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

Оцінка «на серветці» - швидкий розрахунок порядків величин до проєктування: скільки запитів на секунду, скільки даних, скільки серверів. Мета - не точність, а розуміння, чи це задача для одного сервера чи для розподіленої системи.

Приклад: сервіс коротких посилань.

1. Припущення:

  • 1 млн нових посилань на день;
  • переходів у 100 разів більше - 100 млн на день;
  • один запис - ~500 байтів (URL, код, метадані).

2. Запити на секунду (у добі ~86 400 с, для простоти ~100 000):

  • записи: 1 млн / 100 000 ≈ 10 на секунду;
  • читання: 100 млн / 100 000 ≈ 1000 на секунду;
  • піки - у 3-5 разів вище середнього: до 5000 читань на секунду.

3. Сховище:

  • 1 млн × 500 байтів = 500 МБ на день;
  • × 365 × 5 років ≈ ~1 ТБ за п'ять років.

4. Висновки:

  • 10 записів на секунду - тривіально для однієї бази;
  • 5000 читань на секунду на простий пошук за ключем - під силу одній базі з індексом, але кеш (Redis) перед нею майже повністю прибере навантаження: популярні посилання - мала частка всіх;
  • 1 ТБ за 5 років - одна база, без шардингу.

Корисні числа, які варто пам'ятати (порядки):

Операція Порядок
читання з пам'яті наносекунди
запит до Redis у тій самій мережі ~0,5-1 мс
простий запит до бази за індексом ~1 мс
запит між дата-центрами десятки мс
процес PHP на запит десятки мс і МБ пам'яті

Чому це цінно на співбесіді: показує, що архітектурні рішення спираються на цифри. Часто розрахунок доводить, що «складна розподілена система» не потрібна - вистачить одного сервера з кешем.

Типові помилки: не враховувати піки (середнє оманливе), забувати про репліки й бекапи в обсязі сховища, ігнорувати співвідношення читань і записів.

Докладніше в документації: System Design Primer: оцінки

Гексагональна архітектура (Алістер Кокберн, 2005), або порти й адаптери, ставить у центр ядро застосунку - бізнес-логіку, яка нічого не знає про зовнішній світ: ні про HTTP, ні про базу, ні про конкретні сервіси.

Порти - інтерфейси, через які ядро спілкується зі світом:

  • вхідні (driving) - як світ звертається до ядра: «оформити замовлення», «скасувати підписку» (сценарії використання);
  • вихідні (driven) - що ядру потрібно від світу: «зберегти замовлення», «списати оплату», «надіслати сповіщення».

Адаптери - реалізації портів для конкретних технологій:

  • вхідні: HTTP-контролер, консольна команда, обробник черги, тест;
  • вихідні: репозиторій на Eloquent, клієнт Stripe, відправка через Mailgun.
// вихідний порт - у ядрі
interface PaymentGateway
{
    public function charge(Money $amount, PaymentMethod $method): PaymentResult;
}

// адаптер - на краю
final class StripePaymentGateway implements PaymentGateway { /* SDK Stripe */ }

// тестовий адаптер
final class FakePaymentGateway implements PaymentGateway { /* у пам'яті */ }

Чому «шестикутник»: форма не має значення - вона лише показує, що в ядра багато рівноправних «сторін» для підключення, а не «верх» (UI) і «низ» (база), як у шаровій архітектурі.

Що дає:

  • тестованість: ядро тестується з тестовими адаптерами - без HTTP, бази й мережі;
  • заміна технологій: новий платіжний провайдер - новий адаптер, ядро не змінюється;
  • кілька входів: той самий сценарій викликається з вебу, API, CLI, черги;
  • відкладені рішення: можна почати з адаптера в пам'яті й обрати технологію пізніше.

Ціна:

  • більше інтерфейсів, класів, відображень між доменними об'єктами й моделями сховища;
  • у Laravel - протистояння з Eloquent (Active Record змішує домен і збереження), тож повна гексагональність означає відмову від частини зручностей фреймворку.

Прагматичний підхід: порти й адаптери - для меж із зовнішніми системами (платежі, пошта, сторонні API, пошук), де заміна й тестування справді потрібні. А для збереження власних даних - Eloquent напряму, доки предметна область не стане настільки складною, що ізоляція окупиться.

Докладніше в документації: Alistair Cockburn: Hexagonal Architecture

Чиста архітектура (Роберт Мартін, 2012) узагальнює гексагональну, «цибулеву» та інші подібні архітектури у вигляді концентричних кіл:

  ┌──────────────────────────────────────────┐
  │ Фреймворки й драйвери (веб, БД, UI)       │
  │  ┌────────────────────────────────────┐  │
  │  │ Адаптери (контролери, репозиторії)  │  │
  │  │  ┌──────────────────────────────┐  │  │
  │  │  │ Сценарії (use cases)          │  │  │
  │  │  │  ┌────────────────────────┐  │  │  │
  │  │  │  │ Сутності (бізнес-правила)│ │  │  │

Правило залежностей - головне: залежності в коді спрямовані лише всередину. Внутрішнє коло нічого не знає про зовнішні - ні назв класів, ні функцій, ні форматів даних.

  • сутності - найзагальніші бізнес-правила, що існували б і без програми («замовлення не можна оплатити двічі»);
  • сценарії використання - правила конкретного застосунку («оформлення замовлення: перевірити залишки, зарезервувати, створити рахунок»);
  • адаптери - перетворюють дані між форматом сценаріїв і форматом зовнішніх систем;
  • фреймворки й драйвери - Laravel, база, черги, HTTP.

Як виконати правило, якщо сценарію потрібна база? Через інверсію залежностей: сценарій оголошує інтерфейс (OrderRepository), а реалізація в зовнішньому колі його імплементує. Залежність у коді - всередину (адаптер залежить від інтерфейсу ядра), хоча виклик іде назовні.

Що це дає:

  • бізнес-правила не залежать від фреймворку - оновлення Laravel чи заміна бази не зачіпає ядро;
  • ядро тестується без інфраструктури;
  • рішення про технології можна відкладати.

Критика й реальність:

  • багато шаблонного коду: DTO на кожній межі, відображення моделей, інтерфейси з однією реалізацією;
  • у типовому CRUD-застосунку сценарії тонкі, і накладні витрати не окупаються;
  • Laravel побудований навколо протилежної філософії - продуктивність через тісну інтеграцію (Eloquent, фасади, хелпери).

Прагматичний висновок для співбесіди: правило залежностей корисне як принцип навіть без повних «кіл» - бізнес-логіка не повинна залежати від деталей доставки (HTTP-запиту, сесії, формату API). Повну чисту архітектуру варто застосовувати там, де складність предметної області справді переважає технічну.

Докладніше в документації: Robert Martin: The Clean Architecture

Архітектурні домовленості («контролери не звертаються до бази напряму», «домен не залежить від HTTP») зазвичай живуть у документації чи головах - і порушуються непомітно. Arch-тести перетворюють їх на тести, що падають при порушенні.

Pest arch():

arch('domain does not depend on the framework layer')
    ->expect('App\Domain')
    ->not->toUse(['Illuminate\Http', 'App\Http']);

arch('controllers stay thin')
    ->expect('App\Http\Controllers')
    ->not->toUse('Illuminate\Support\Facades\DB');

arch('actions are final and invokable')
    ->expect('App\Actions')
    ->toBeFinal()
    ->toHaveMethod('handle');

arch('no debugging leftovers')
    ->expect(['dd', 'dump', 'ray', 'var_dump'])
    ->not->toBeUsed();

Готові пресети:

arch()->preset()->php();        // без застарілих і небезпечних функцій PHP
arch()->preset()->security();   // без eval, exec, unserialize, md5, sha1, rand тощо
arch()->preset()->laravel();    // конвенції Laravel
arch()->preset()->strict();     // строгі типи, final-класи, без protected у final
arch()->preset()->relaxed();    // м'якший варіант

Пресет laravel() перевіряє, наприклад, що класи в App\Models наслідують Model і не мають суфікса Model, а Form Request-и мають метод rules() і суфікс Request. Окремі правила пресету можна виключити через ->ignoring(...).

Що зручно перевіряти:

  • напрям залежностей між модулями чи шарами - головне застосування;
  • межі модулів: App\Billing не використовує внутрішні класи App\Catalog, лише його публічний API;
  • конвенції: суфікси, final, інтерфейси для певних каталогів, відсутність env() поза config/;
  • заборонені виклики: dd, DB::raw у певних шарах, прямі HTTP-виклики поза клієнтами.

Чому це цінно:

  • правила перевіряються в CI - порушення видно в pull request, а не через рік;
  • тести - виконувана документація архітектури;
  • нова людина в команді дізнається про правило від тесту, а не з коментаря в рев'ю.

Обмеження: arch-тести бачать статичні залежності (використання класів, функцій). Залежності через рядки, контейнер чи фасади можуть пройти непомітно. Це «запобіжник», а не повна гарантія.

Цей підхід - приклад «функцій придатності» еволюційної архітектури: автоматичних перевірок архітектурних властивостей.

Докладніше в документації: Pest: тестування архітектури

Модель C4 (Саймон Браун) - спосіб описувати архітектуру на чотирьох рівнях деталізації, як карта з різним масштабом:

1. Контекст системи (Context). Система як одна коробка, навколо - користувачі й зовнішні системи, з якими вона взаємодіє. Для всіх, включно з нетехнічними людьми.

[Покупець] → [Інтернет-магазин] → [Платіжна система]
                       ↓
               [Служба доставки]

2. Контейнери (Containers). Що всередині системи як окремо розгортається й запускається: вебзастосунок Laravel, SPA, мобільний застосунок, база PostgreSQL, Redis, воркери черг, пошуковий рушій. «Контейнер» тут - не Docker, а окрема одиниця, що виконує код чи зберігає дані.

3. Компоненти (Components). Структура всередині одного контейнера: модулі, основні сервіси, їх відповідальності й залежності.

4. Код (Code). Класи й зв'язки (UML) - зазвичай не малюється вручну, бо швидко застаріває; генерується з коду за потреби.

Чому C4 зручна:

  • різні аудиторії - бізнесу показують рівень 1, новим розробникам - 1-3;
  • єдина нотація замість «квадратиків і стрілок», де кожен розуміє позначення по-своєму;
  • кожна стрілка підписана (що передається, яким протоколом) - найчастіший недолік неформальних діаграм.

Як не дати документації застаріти:

  • малювати лише рівні 1-2 (іноді 3) - вони змінюються рідко. Детальніше документує сам код;
  • діаграми як код - Structurizr DSL, PlantUML, Mermaid у репозиторії: змінюються в тому самому pull request, що й код, і рецензуються разом;
  • поруч із кодом, а не в окремій вікі, яку ніхто не відкриває;
  • ADR для рішень, C4 - для структури: діаграма показує «що», ADR - «чому»;
  • перевірка на онбордингу: нова людина проходить документацію і позначає, що не відповідає реальності.

Додаткові діаграми C4: динамічні (послідовність взаємодії для важливого сценарію) і розгортання (як контейнери розміщені на серверах і в хмарі).

Головна думка: документація архітектури має бути достатньою, а не повною. Діаграма контексту й контейнерів, що актуальні, - цінніші за сотню докладних, але застарілих.

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

Trunk-based development - усі розробники часто (щодня чи частіше) зливають зміни в основну гілку, без довгоживучих гілок фіч. Прапорці функцій (feature flags) роблять це можливим: незавершена функція потрапляє в продакшен вимкненою.

Навіщо так робити:

  • без болісних злиттів: гілка, що жила місяць, конфліктує з усім; щоденні маленькі злиття - ні;
  • розгортання ≠ реліз: код деплоїться коли завгодно, а функція вмикається окремим рішенням - для внутрішніх користувачів, 5% аудиторії, конкретної команди;
  • швидкий відкат: проблема - вимкнути прапорець за секунди, без деплою;
  • експерименти: A/B-тести різних варіантів.

Види прапорців (за класифікацією з статті Фаулера):

  • релізні - приховують незавершене; живуть дні чи тижні;
  • операційні («аварійні вимикачі») - вимкнути важку функцію під навантаженням; живуть довго;
  • експериментальні - A/B-тести;
  • дозволи - функції для певних тарифів чи ролей; фактично частина продукту.

У Laravel - Laravel Pennant:

Feature::define('new-checkout', fn (User $user) => $user->isInternal() || Lottery::odds(1, 20)->choose());

if (Feature::active('new-checkout')) {
    return $this->newCheckout($request);
}
@feature('new-checkout') ... @endfeature

Архітектурні наслідки:

  • код має підтримувати обидва шляхи одночасно - а це прямо впливає на дизайн: нову реалізацію зручно додати поруч зі старою за спільним інтерфейсом (стратегія, «branch by abstraction»), а не переписувати старий код на місці;
  • міграції бази - сумісні в обидва боки: нова колонка додається до використання, стара видаляється після вимкнення прапорця (expand/contract);
  • тестування комбінацій - обидва стани прапорця мають бути протестовані.

Головний ризик - накопичення прапорців. Кожен прапорець подвоює кількість можливих станів системи. Релізний прапорець, що лишився в коді після повного запуску, - технічний борг. Практика: дата видалення при створенні, задача на прибирання, інвентаризація.

Передумови trunk-based development: швидкі автоматичні тести в CI, невеликі pull request-и й швидке рев'ю - інакше «часті злиття» ламатимуть основну гілку.

Докладніше в документації: Martin Fowler: Feature Toggles

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

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії