Middle: питання на співбесіді з теми «Чиста архітектура й тестованість»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
Гексагональна архітектура (Алістер Кокберн, 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-и й швидке рев'ю - інакше «часті злиття» ламатимуть основну гілку.