Питання на співбесіді з Архітектура
Питання з реальних співбесід з відповідями: 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 на продакшені під навантаженням - ризикована операція.
У більшості вебзастосунків читань у десятки разів більше, ніж записів. Репліки дають змогу розподілити читання між кількома серверами бази, а всі записи лишити на основному (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 до тисяч - база почне деградувати від кількості з'єднань раніше, ніж від запитів.
Оцінка «на серветці» - швидкий розрахунок порядків величин до проєктування: скільки запитів на секунду, скільки даних, скільки серверів. Мета - не точність, а розуміння, чи це задача для одного сервера чи для розподіленої системи.
Приклад: сервіс коротких посилань.
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 на запит | десятки мс і МБ пам'яті |
Чому це цінно на співбесіді: показує, що архітектурні рішення спираються на цифри. Часто розрахунок доводить, що «складна розподілена система» не потрібна - вистачить одного сервера з кешем.
Типові помилки: не враховувати піки (середнє оманливе), забувати про репліки й бекапи в обсязі сховища, ігнорувати співвідношення читань і записів.
Гексагональна архітектура (Алістер Кокберн, 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-тести бачать статичні залежності (використання класів, функцій). Залежності через рядки, контейнер чи фасади можуть пройти непомітно. Це «запобіжник», а не повна гарантія.
Цей підхід - приклад «функцій придатності» еволюційної архітектури: автоматичних перевірок архітектурних властивостей.
Модель 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: динамічні (послідовність взаємодії для важливого сценарію) і розгортання (як контейнери розміщені на серверах і в хмарі).
Головна думка: документація архітектури має бути достатньою, а не повною. Діаграма контексту й контейнерів, що актуальні, - цінніші за сотню докладних, але застарілих.
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-и й швидке рев'ю - інакше «часті злиття» ламатимуть основну гілку.
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: стани як класи, збережені в колонці моделі, з описаними переходами й власними класами-переходами для логіки.
Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії