Питання на співбесіді: Чиста архітектура й тестованість
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
14 питань
Шарова архітектура ділить застосунок на горизонтальні шари з різною відповідальністю. Кожен шар залежить лише від шару під ним.
Класичні три шари:
- представлення (presentation) - взаємодія з користувачем чи клієнтом: контролери, шаблони, Livewire-компоненти, ресурси API, консольні команди;
- домен (бізнес-логіка) - правила предметної області: розрахунок ціни, перевірка, чи можна скасувати замовлення, нарахування знижок;
- дані (data) - збереження й отримання даних: запити до бази, сторонні API, файлове сховище.
Часто між представленням і доменом виділяють ще шар застосунку (application/service layer) - сценарії використання: «оформити замовлення» координує доменну логіку, транзакцію, відправку листа.
Навіщо:
- зрозуміло, де що шукати - бізнес-правило не розкидане по контролерах і шаблонах;
- зміни локалізовані - новий формат API змінює шар представлення, а не бізнес-логіку;
- повторне використання - той самий сценарій викликається з контролера, команди й джоби;
- тестування - бізнес-логіку можна перевірити без HTTP.
У Laravel це виглядає приблизно так:
Http/Controllers, Livewire, Console ← представлення
Actions / Services ← сценарії
Models (+ доменні класи, Enums) ← домен і дані разом (Active Record)
Eloquent за патерном Active Record поєднує доменний об'єкт і доступ до даних - шари «домен» і «дані» тут свідомо злиті заради простоти.
Типові помилки:
- «товстий контролер» - уся логіка в контролері, неможливо використати з команди чи протестувати окремо;
- «анемічні шари» - сервіс, що лише викликає репозиторій, який лише викликає модель, - шари без змісту;
- залежності не в той бік - модель викликає контролер чи шаблон.
Мартін Фаулер радить на рівні великої системи ділити спершу за предметними областями (замовлення, каталог, оплата), а шари - всередині кожної: інакше кожна зміна фічі зачіпає всі шари всього застосунку одночасно.
Докладніше в документації: Martin Fowler: Presentation Domain Data Layering
YAGNI (You Aren't Gonna Need It) - «вам це не знадобиться»: не реалізовувати можливість, доки вона справді не потрібна.
KISS (Keep It Simple) - обирати найпростіше рішення, що розв'язує задачу.
Обидва - про боротьбу з передчасною складністю, але з різних боків: YAGNI - про що будувати (не будувати зайвого), KISS - про як (не ускладнювати те, що будуєте).
Типові порушення YAGNI:
- інтерфейс і три реалізації «на випадок, якщо колись зміниться платіжний провайдер»;
- налаштування й прапорці, які ніхто не просив;
- узагальнений «рушій правил» для двох правил знижок;
- мікросервіси для застосунку з однією командою й десятком користувачів;
- підтримка кількох баз даних, хоча застосунок ніколи не змінить базу.
Чому це дорого (за Фаулером):
- вартість побудови - час на непотрібне замість потрібного зараз;
- вартість затримки - потрібна функція виходить пізніше;
- вартість підтримки - кожен рядок треба читати, тестувати, оновлювати, і він ускладнює зміни поруч;
- вартість помилки прогнозу - коли можливість таки знадобиться, вона часто потрібна інакше, ніж її передбачили, і готову абстракцію доводиться ламати.
Важливе уточнення: YAGNI стосується можливостей, а не якості коду. Він не означає «не писати тестів», «не рефакторити» чи «не думати про структуру». Навпаки - чистий, протестований код дешево змінювати, коли нова вимога справді прийде. Саме це робить YAGNI безпечним.
Як це виглядає в Laravel-проєкті:
- спершу - Eloquent напряму в діях, без репозиторіїв «на майбутнє»;
- один клас-сервіс замість ієрархії стратегій, доки варіант один;
- конфігурація в
config/, а не адмінка налаштувань, доки її ніхто не змінює.
Коли передбачення виправдане: рішення, які дорого змінити потім - схема публічного API, формат даних, вибір основної бази, безпека. Тут думати наперед доречно; у внутрішньому коді - краще відкласти.
Проблема: клас сам створює свої залежності - і в тесті їх неможливо замінити.
class OrderService
{
public function place(Order $order): void
{
$gateway = new StripeGateway(config('services.stripe.key')); // справжній платіж у тесті?
$gateway->charge($order->total);
}
}
Впровадження залежностей - клас отримує залежності ззовні (зазвичай через конструктор):
class OrderService
{
public function __construct(private PaymentGateway $gateway) {}
public function place(Order $order): void
{
$this->gateway->charge($order->total);
}
}
Сервіс-контейнер Laravel створить OrderService і підставить реалізацію PaymentGateway, зареєстровану в провайдері. А в тесті можна підставити тестовий дублер.
Види тестових дублерів (за Джерардом Месарошем):
- dummy - об'єкт-заглушка, що лише заповнює параметр і не використовується;
- stub - повертає заздалегідь задані відповіді («платіж завжди успішний»);
- spy - запам'ятовує виклики, щоб потім перевірити, як його використовували;
- mock - заздалегідь налаштовані очікування викликів, що перевіряються автоматично;
- fake - робоча спрощена реалізація (база в пам'яті, платіжний шлюз, що зберігає транзакції в масиві).
У Laravel багато фейків уже готові:
Mail::fake();
Queue::fake();
Http::fake(['api.stripe.com/*' => Http::response(['status' => 'succeeded'])]);
Storage::fake('s3');
$this->app->instance(PaymentGateway::class, new FakePaymentGateway());
Чому fake часто кращі за mock: mock перевіряє, як код викликає залежність (порядок, аргументи), і ламається при будь-якому рефакторингу. Fake перевіряє результат («після оформлення в шлюзі є одна транзакція на 500 грн») - тести стійкіші.
Пастка надмірних моків: тест, де все замокано, перевіряє лише те, що код викликає моки так, як ви їх налаштували. Моки - для меж системи (сторонні API, пошта, час, випадковість), а не для кожного внутрішнього класу.
ADR (Architecture Decision Record) - короткий документ про одне архітектурне рішення: що вирішили, чому, які були альтернативи й наслідки.
Навіщо: через рік ніхто не пам'ятає, чому обрали саме Redis для черг, чому немає мікросервісів, чому гроші зберігаються в копійках. Нова людина в команді бачить дивне рішення і або боїться його чіпати, або «виправляє» - і повторює помилку, від якої колись свідомо відмовилися.
Типова структура (шаблон Майкла Найгарда):
# 7. Зберігати суми в копійках цілим числом
## Статус
Прийнято (2026-03-12)
## Контекст
Розрахунки з float давали розбіжності в копійку в звітах.
Декілька валют, у деяких - інша кількість знаків після коми.
## Рішення
Усі суми - BIGINT у мінімальних одиницях + код валюти.
Перетворення у форматований вигляд - лише при виведенні.
## Наслідки
+ точні розрахунки, прості порівняння
- міграція наявних колонок, зміна API (поле amount стає цілим)
Принципи:
- один ADR - одне рішення;
- незмінність: прийнятий ADR не редагують по суті. Якщо рішення змінилося - новий ADR, а старий отримує статус «Замінено ADR-15». Так зберігається історія мислення;
- поруч із кодом - у репозиторії (
docs/adr/0007-money-in-cents.md), рецензується в pull request, як код; - коротко - сторінка, а не трактат.
Що варто записувати: рішення, що дорого змінити або що виглядатимуть неочевидними: вибір фреймворку чи бази, структура модулів, формат API, підхід до автентифікації, відмова від чогось популярного («чому ми не використовуємо репозиторії»).
Що не варто: дрібні рішення, які видно з коду й легко змінити.
Додаткова користь: написання ADR змушує сформулювати альтернативи й компроміси - рішення стають обдуманішими ще до того, як їх зафіксували. А в рев'ю ADR команда обговорює ідею до тижнів реалізації.
Тестова піраміда (Майк Кон) - модель розподілу тестів за рівнями:
/\ E2E / UI - мало: повільні, крихкі, дорогі
/ \
/----\ інтеграційні - середньо
/ \
/--------\ модульні (unit) - багато: швидкі, дешеві, точні
Логіка: чим вище рівень, тим тест повільніший, крихкіший і дорожчий у підтримці, але тим більше впевненості він дає, що система працює цілком. Тому основу складають швидкі модульні тести, а наскрізних - небагато, на головні сценарії.
«Тестовий трофей» (Кент Доддс), популярний у фронтенді:
🏆 E2E - кілька
----
| | інтеграційні - НАЙБІЛЬШЕ
|----|
unit - трохи
======
статичний аналіз - основа: типи, лінтери
Аргумент: модульні тести окремих функцій часто перевіряють деталі реалізації й не ловлять помилок взаємодії, а сучасні інструменти зробили інтеграційні тести досить швидкими. «Чим більше тести схожі на те, як використовують програму, тим більше впевненості вони дають».
Як це виглядає в Laravel-проєкті:
- статичний аналіз - PHPStan/Larastan, Pint: ловлять цілі класи помилок без жодного тесту;
- feature-тести (основа більшості Laravel-проєктів) - HTTP-запит до маршруту з реальною базою (
RefreshDatabase), фейками пошти й черг. Фактично інтеграційні, але досить швидкі; - unit-тести - для чистої логіки зі складними розгалуженнями: розрахунок ціни, парсери, правила;
- браузерні тести (Pest browser, Dusk) - для критичних сценаріїв з JavaScript.
Де архітектура має значення: якщо бізнес-логіка розмазана по контролерах і моделях, перевірити її можна лише дорогими тестами верхнього рівня. Винесена в окремі класи з явними залежностями - тестується дешево й точно.
Правильний розподіл - той, що дає впевненість у змінах за прийнятний час. Обидві моделі - орієнтири, а не закони: важливо, щоб набір тестів запускався швидко, був стабільним і ловив реальні регресії.
Докладніше в документації: Martin Fowler: The Practical Test Pyramid
Гексагональна архітектура (Алістер Кокберн, 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-и й швидке рев'ю - інакше «часті злиття» ламатимуть основну гілку.
Позиція «фреймворк - деталь» (чиста архітектура): бізнес-логіка не повинна знати про Laravel. Ніяких моделей Eloquent, фасадів, хелперів у ядрі - лише чисті PHP-класи, інтерфейси й DTO, а фреймворк лише доставляє запити й зберігає дані.
Позиція Laravel-спільноти: фреймворк - не деталь, а інструмент продуктивності. Eloquent, фасади, черги, події дають швидкість розробки саме завдяки тісній інтеграції. Абстрагування від них - це подвоєння коду заради заміни, яка ніколи не станеться.
Що коштує повна ізоляція:
- окремі доменні сутності й Eloquent-моделі + відображення між ними;
- репозиторії-обгортки над Eloquent з інтерфейсами;
- DTO на кожній межі;
- втрата зручностей (зв'язки, ліниве завантаження, події моделей,
toResource()); - новим людям складніше: «Laravel-проєкт» не схожий на Laravel.
Що вона дає:
- бізнес-правила тестуються миттєво, без бази й контейнера;
- складна предметна область (фінанси, страхування, логістика) не тоне в інфраструктурному коді;
- довговічність: ядро переживає великі оновлення фреймворку.
Прагматичний середній шлях, що працює в більшості проєктів:
- дії (actions) чи сервіси для сценаріїв - бізнес-логіка не в контролерах, моделях чи Livewire-компонентах;
- Eloquent лишається доменною моделлю - Active Record з методами домену (
$order->cancel()), скоупами й кастами; - ізолювати зовнішні системи, а не власну базу: інтерфейси для платежів, SMS, сторонніх API - тут заміна й фейки реально потрібні;
- value objects і енуми для доменних понять (гроші, статуси, діапазони дат);
- arch-тести на напрям залежностей (домен не імпортує
Illuminate\Http); - фасади - за потреби, з розумінням, що в тестах вони підміняються (
Mail::fake()).
Критерій вибору: складність предметної області порівняно з технічною. CRUD-адмінка, каталог, блог - Laravel-шлях. Складні правила з багатьма інваріантами й довгим життям - більше ізоляції, хоча б для ядра цієї частини (модульний моноліт, де лише складний модуль має «чисту» архітектуру).
Найгірший варіант - половинчастий: інтерфейси репозиторіїв, що повертають моделі Eloquent, і «сервіси», що лише проксюють виклики. Складність є, а користі немає.
Еволюційна архітектура (Ніл Форд, Ребекка Парсонс, Патрік Куа) - підхід, за яким архітектуру не проєктують раз і назавжди, а постійно змінюють разом із системою. Ключове питання - як змінювати, не втрачаючи важливих властивостей (продуктивності, безпеки, модульності).
Функція придатності (fitness function) - автоматична перевірка конкретної архітектурної властивості. Термін запозичено з еволюційних алгоритмів, де функція придатності оцінює, наскільки рішення близьке до мети.
Приклади функцій придатності:
- модульність: arch-тести Pest - «модуль Billing не залежить від внутрішніх класів Catalog», «домен не імпортує HTTP-шар»;
- продуктивність: тест, що головна сторінка робить не більше 15 SQL-запитів (
expectsDatabaseQueryCount); бюджет часу відповіді в навантажувальних тестах; - розмір збірки фронтенду: CI падає, якщо JavaScript-бандл перевищив 250 КБ;
- безпека:
composer auditбез вразливостей високого рівня, заборонені функції черезarch()->preset()->security(); - якість: рівень PHPStan, покриття критичних модулів тестами;
- операційні: моніторинг SLO в продакшені - «функція придатності», що працює постійно.
Види:
- атомарні (одна властивість) і цілісні (поєднання - продуктивність разом із безпекою);
- тригерні (запускаються в CI на кожну зміну) і безперервні (моніторинг у продакшені);
- статичні (правила коду) і динамічні (вимірювання під навантаженням).
Чому це важливо: архітектура деградує поступово - кожен окремий pull request «трохи» порушує межі, і через рік модульний моноліт стає великою грудкою бруду. Функції придатності роблять архітектурні рішення виконуваними: порушення помітне в момент внесення, а не на ретроспективі.
Інші принципи еволюційної архітектури:
- останній відповідальний момент для рішень - вирішувати, коли знань достатньо, але до того, як відкладання стане дорожчим;
- зменшення зв'язності - чим менше залежностей, тим легше змінювати частини незалежно;
- малі інкрементальні зміни з можливістю відкату - замість великих переписувань.
Як почати: обрати 2-3 властивості, порушення яких найболючіші для проєкту (часто - межі модулів і N+1), і автоматизувати їх перевірку в CI.
Докладніше в документації: Building Evolutionary Architectures
Роберт Мартін запропонував метрики, що перетворюють розмови про «зв'язність модулів» на числа.
Аферентна зв'язність (Ca) - скільки інших модулів залежать від цього. Висока Ca - модуль важливий для багатьох; його зміна зачіпає багатьох.
Еферентна зв'язність (Ce) - від скількох модулів залежить цей. Висока Ce - модуль залежить від багатьох; зміни в будь-якому з них можуть його зламати.
Нестабільність:
I = Ce / (Ca + Ce) від 0 до 1
- I ≈ 0 - стабільний модуль: від нього багато залежать, він сам ні від кого. Змінювати його дорого (багато споживачів). Приклад: спільні value objects, базові інтерфейси, ядро предметної області;
- I ≈ 1 - нестабільний: ні від нього ніхто, а він від багатьох. Змінювати легко й безпечно. Приклад: контролери, консольні команди, UI.
Принцип стабільних залежностей: залежності мають іти в напрямку стабільності - нестабільні модулі залежать від стабільних, а не навпаки. Якщо стабільний модуль (від якого залежить половина системи) залежить від нестабільного, кожна зміна нестабільного «трусить» усю систему.
Абстрактність (A) - частка абстрактних класів та інтерфейсів у модулі. Принцип стабільних абстракцій: стабільні модулі мають бути абстрактними (щоб їх можна було розширювати без зміни), нестабільні - конкретними.
Головна послідовність: ідеально A + I ≈ 1. Відхилення показує проблемні зони:
- «зона болю» (стабільний і конкретний: I ≈ 0, A ≈ 0) - від модуля всі залежать, але його неможливо розширити без зміни. Типово для «утилітних» класів і перевантажених базових моделей;
- «зона марності» (нестабільний і абстрактний) - інтерфейси, які ніхто не використовує.
Як застосувати в PHP-проєкті:
- інструменти: PhpMetrics, Deptrac (ще й перевіряє дозволені залежності між шарами), pdepend;
- дивитися на тенденції між релізами, а не на абсолютні значення;
- для модульного моноліту - головні метрики на рівні модулів (
Billing,Catalog), а не окремих класів.
Застереження: метрики - сигнал для розмови, а не мета. Оптимізувати «I» заради числа - створити штучні інтерфейси. Корисне питання: «чому від цього модуля залежить усе?» - і чи має так бути.
У великій команді один архітектор, через якого проходять усі рішення, стає вузьким місцем: рішення чекають тижнями, а ухвалюються далеко від людей, що знають деталі. У маленькій - навпаки, рішення ухвалюються «мимохідь», і через рік ніхто не пам'ятає чому.
Поділ рішень за вартістю зміни:
- оборотні («двосторонні двері») - легко змінити: структура класів усередині модуля, вибір бібліотеки для внутрішньої задачі. Ухвалюються швидко, тими, хто робить роботу;
- необоротні («односторонні двері») - дорого змінити: формат публічного API, основна база, розбиття на сервіси, модель даних, автентифікація. Тут варто зупинитися, зібрати думки й записати рішення.
Процес порад (advice process) - підхід, який описує Андрю Гармел-Ло в статті на сайті Мартіна Фаулера:
- будь-хто може ухвалити архітектурне рішення, але спершу мусить порадитися з тими, кого воно зачепить, і з тими, хто має досвід у цій галузі;
- порада не є вето - відповідальність за рішення лишається на тому, хто його ухвалює;
- рішення фіксується в ADR - разом із контекстом, альтернативами й отриманими порадами;
- архітектурний форум (регулярна зустріч) - місце, де обговорюють поточні рішення й діляться ними, а не орган затвердження.
Інструменти, що підтримують такий підхід:
- ADR - пам'ять про рішення і їхні причини;
- принципи архітектури - кілька загальноприйнятих правил («межі модулів не перетинаємо напряму», «нові інтеграції - через черги»), щоб більшість рішень можна було ухвалити самостійно, звіряючись із ними;
- технологічний радар - перелік технологій зі статусами «використовуємо», «пробуємо», «не використовуємо»;
- функції придатності - автоматична перевірка того, що вже вирішено.
Чого уникати:
- рішення без обговорення з тими, хто житиме з наслідками (експлуатація, безпека, інші команди);
- комітет, що затверджує все - повільно й знімає відповідальність з авторів;
- рішення без запису - через рік їх «виправляють» люди, які не знають контексту.
На співбесіді важливо показати, що архітектура - не лише технічні знання, а й процес: як збирати інформацію, зважувати компроміси, документувати й поширювати рішення.
Докладніше в документації: Martin Fowler: Scaling the Practice of Architecture, Conversationally