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

Питання на співбесіді: Чиста архітектура й тестованість

Питання з реальних співбесід з відповідями: 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, формат даних, вибір основної бази, безпека. Тут думати наперед доречно; у внутрішньому коді - краще відкласти.

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

Проблема: клас сам створює свої залежності - і в тесті їх неможливо замінити.

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, пошта, час, випадковість), а не для кожного внутрішнього класу.

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

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 команда обговорює ідею до тижнів реалізації.

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

Тестова піраміда (Майк Кон) - модель розподілу тестів за рівнями:

        /\        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-тести бачать статичні залежності (використання класів, функцій). Залежності через рядки, контейнер чи фасади можуть пройти непомітно. Це «запобіжник», а не повна гарантія.

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

Докладніше в документації: 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

Позиція «фреймворк - деталь» (чиста архітектура): бізнес-логіка не повинна знати про 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, і «сервіси», що лише проксюють виклики. Складність є, а користі немає.

Докладніше в документації: Laravel: сервіс-контейнер

Еволюційна архітектура (Ніл Форд, Ребекка Парсонс, Патрік Куа) - підхід, за яким архітектуру не проєктують раз і назавжди, а постійно змінюють разом із системою. Ключове питання - як змінювати, не втрачаючи важливих властивостей (продуктивності, безпеки, модульності).

Функція придатності (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» заради числа - створити штучні інтерфейси. Корисне питання: «чому від цього модуля залежить усе?» - і чи має так бути.

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

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

Поділ рішень за вартістю зміни:

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

Процес порад (advice process) - підхід, який описує Андрю Гармел-Ло в статті на сайті Мартіна Фаулера:

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

Інструменти, що підтримують такий підхід:

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

Чого уникати:

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

На співбесіді важливо показати, що архітектура - не лише технічні знання, а й процес: як збирати інформацію, зважувати компроміси, документувати й поширювати рішення.

Докладніше в документації: Martin Fowler: Scaling the Practice of Architecture, Conversationally