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

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

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

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