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

Питання на співбесіді з Архітектура

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

100 питань

Технічний борг - метафора Ворда Каннінгема: швидке, неідеальне рішення - як позика. Ви отримуєте швидкість зараз, але платите відсотки: кожна наступна зміна в цьому коді коштує дорожче. Якщо борг не гасити, відсотки з'їдають усю швидкість команди.

Борг буває різним (квадрант Мартіна Фаулера):

  • Свідомий і обачний: «випускаємо зараз, бо дедлайн, і знаємо, що треба буде переробити». Нормальне бізнес-рішення.
  • Свідомий і необачний: «на дизайн немає часу» - постійно.
  • Несвідомий і обачний: «тепер ми розуміємо, як це треба було зробити» - природний наслідок навчання.
  • Несвідомий і необачний: команда просто не знає, як краще.

Як керувати:

  • Робити видимим. Задачі в трекері з описом наслідків, а не «відрефакторити X»: «додавання нового способу оплати займає 3 дні замість пів дня через Y».
  • Говорити мовою бізнесу. «Рефакторинг» не продається; «зменшимо кількість інцидентів у платежах» і «пришвидшимо релізи нових інтеграцій» - продаються.
  • Гасити постійно, а не «колись». Частина ємності кожного спринту, правило бойскаута при роботі в коді, а не окремий «спринт рефакторингу» раз на рік.
  • Пріоритезувати за відсотками. Найдорожчий борг - у коді, який часто змінюється. Заплутаний модуль, якого ніхто не чіпав три роки, може почекати. Аналіз «hotspots» (частота змін × складність файлів з історії Git) показує, де борг коштує найбільше.
  • Не накопичувати нового непомітно: код-рев'ю, статичний аналіз у CI, тести як умова злиття.

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

Докладніше в документації: Technical Debt

Bounded context (обмежений контекст) - поняття з DDD: межа, всередині якої терміни й модель мають одне чітке значення. У великій системі одне слово означає різне в різних частинах.

«Товар» у каталозі - опис, фото, характеристики. На складі - залишки, комірка, вага. У бухгалтерії - собівартість і податкова група. Спроба зробити одну модель Product для всіх трьох дає клас на сотні полів, який змінюють усі команди й ніхто не розуміє повністю.

Ідея: кожен контекст має свою модель того, що йому потрібно. Контексти пов'язані через ідентифікатори й явні контракти, а не через спільні таблиці й класи.

Як ділити моноліт на модулі (модульний моноліт):

  1. Знайти межі за мовою й бізнес-процесами: де змінюється значення термінів, які частини змінюються разом, які команди відповідають за що.
  2. Модуль = каталог з публічним API: app/Billing, app/Catalog, app/Shipping. Інші модулі звертаються лише до публічного інтерфейсу (сервіси, події), а не до внутрішніх моделей і таблиць.
  3. Зв'язок між модулями - через події (OrderPlaced → Billing створює рахунок) або явні виклики фасаду модуля.
  4. Свої таблиці для модуля. Запити JOIN через межу модуля - сигнал, що межа неправильна або потрібна копія даних.
  5. Автоматична перевірка меж: архітектурні тести (Pest arch(), Deptrac) забороняють Catalog імпортувати внутрішні класи Billing.

Чому модульний моноліт, а не одразу мікросервіси: межі спершу майже завжди визначають неточно. Перенести код між модулями - рефакторинг; між мікросервісами - міграція даних, нові API й розподілені транзакції. Добре відокремлений модуль легко винести в сервіс пізніше, коли для цього з'явиться справжня причина: незалежне масштабування, окрема команда, інший цикл релізів.

Докладніше в документації: Bounded Context

Великі зміни (заміна ORM-шару, платіжного провайдера, пошукового рушія, переписування ключового модуля) в окремій гілці Git на тижні - класична пастка: гілка відстає від main, злиття стає болісним, а результат не перевірений у продакшені до самого кінця.

Branch by Abstraction - «гілкування» всередині коду замість гілки в Git:

  1. ввести абстракцію перед частиною, що змінюється, і перевести на неї всіх клієнтів - поки з єдиною реалізацією, старою:
interface SearchEngine
{
    public function search(SearchQuery $query): SearchResults;
}

final class DatabaseSearch implements SearchEngine { /* наявний код */ }
  1. поступово писати нову реалізацію поруч - у main, маленькими комітами, з тестами:
final class MeilisearchSearch implements SearchEngine { /* нова */ }
  1. перемикати клієнтів на нову реалізацію - через конфігурацію чи feature flag, частинами:
$this->app->bind(SearchEngine::class, fn () => Feature::active('new-search')
    ? app(MeilisearchSearch::class)
    : app(DatabaseSearch::class));
  1. видалити стару реалізацію, а за потреби - і саму абстракцію, якщо вона більше не потрібна.

Переваги:

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

Інструменти:

  • feature flags - у Laravel - Pennant (Feature::active()), з поступовим ввімкненням для відсотка користувачів;
  • контейнер - перемикання реалізацій без змін у клієнтах;
  • тести на контракт абстракції - однаковий набір тестів для старої й нової реалізацій.

Що може піти не так:

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

Пов'язане: Strangler Fig - та сама ідея на рівні систем і маршрутизації запитів, Branch by Abstraction - на рівні коду всередині застосунку.

Докладніше в документації: Мартін Фаулер: Branch By Abstraction

Ручний рефакторинг великої кодової бази повільний і схильний до помилок. Інструменти беруть на себе механічну частину.

Rector - автоматичні перетворення коду за правилами:

// rector.php
return RectorConfig::configure()
    ->withPaths([__DIR__.'/app', __DIR__.'/tests'])
    ->withPhpSets()                       // сучасний синтаксис для версії PHP з composer.json
    ->withPreparedSets(deadCode: true, codeQuality: true, typeDeclarations: true);
vendor/bin/rector process --dry-run   # показати зміни
vendor/bin/rector process             # застосувати

Що він робить: оновлює синтаксис (властивості конструктора, match, readonly, енуми), додає типи, прибирає мертвий код, переводить між версіями фреймворків (набори для Laravel - пакет driftingly/rector-laravel). Кожне правило - детерміноване перетворення AST, тож результат передбачуваний на тисячах файлів.

PHPStan / Larastan - статичний аналіз: знаходить помилки типів, виклики неіснуючих методів, неправильні аргументи без запуску коду. Larastan додає розуміння Laravel (магія Eloquent, фасади, контейнер).

Baseline - як впровадити аналіз у старий проєкт:

vendor/bin/phpstan analyse --generate-baseline

Усі наявні помилки записуються у phpstan-baseline.neon і ігноруються. Новий код перевіряється за повними правилами - нові помилки не додаються, а базову лінію поступово зменшують. Те саме вміють Psalm і Rector (пропуск окремих правил чи шляхів).

Як це вбудувати в процес:

  • CI: PHPStan, Pint (стиль), Rector в режимі --dry-run і тести на кожен pull request - злиття блокується при помилках;
  • рівень аналізу піднімати поступово: рівень PHPStan 0 → 5 → 8 → max, з baseline на кожному кроці;
  • рефакторинг окремими комітами: механічні зміни Rector не змішувати з бізнес-змінами - рев'ю тоді простіше;
  • тести перед масовими змінами - статичний аналіз не ловить зміну поведінки.

Чого інструменти не роблять: не вирішують, як розділити відповідальності, які абстракції потрібні, як назвати поняття. Вони прибирають механічну роботу, звільняючи час на архітектурні рішення.

Пастка baseline: він може перетворитися на «смітник», куди складають нові помилки, щоб пройти CI. Варто стежити, щоб кількість записів лише зменшувалася.

Докладніше в документації: Документація Rector

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

Аналіз гарячих точок (hotspots, ідея Адама Торнгілла «Код як місце злочину») поєднує два виміри:

  • складність коду - довжина, вкладеність, цикломатична складність файлу чи методу;
  • частота змін - скільки разів файл змінювали за останні місяці (з історії Git).

Гаряча точка = складний код, який часто змінюють. Саме там зосереджені витрати часу команди й помилки: кожна зміна складного файлу повільна й ризикована, і робиться вона часто.

# найчастіше змінювані файли за рік
git log --since="1 year ago" --name-only --format="" | sort | uniq -c | sort -rn | head -20

Перетин цього списку з найскладнішими файлами (за даними PHPStan, phpmetrics, PhpStorm) дає короткий список кандидатів.

Чому це краще за інтуїцію:

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

Додаткові сигнали з історії Git:

  • зв'язок змін (change coupling): файли, які майже завжди змінюються разом, - прихована залежність чи неправильно розділена відповідальність;
  • знання авторів: файли, які змінювала одна людина, - ризик для команди («bus factor»);
  • частота виправлень: коміти з «fix» у тих самих файлах.

Як діяти з гарячою точкою:

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

Інструменти: CodeScene (комерційний), git log з простими скриптами, phpmetrics, SonarQube - для оцінки складності.

Докладніше в документації: CodeScene: гарячі точки

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

use Illuminate\Support\Facades\Pipeline;

$order = Pipeline::send($order)
    ->through([
        EnsureItemsAvailable::class,
        ApplyPromoCode::class,
        CalculateShipping::class,
        CalculateTaxes::class,
    ])
    ->thenReturn();
final class ApplyPromoCode
{
    public function __construct(private PromoCodes $promoCodes) {}

    public function handle(Order $order, Closure $next): Order
    {
        if ($order->promo_code) {
            $order->discount = $this->promoCodes->discountFor($order);
        }

        return $next($order);
    }
}

Кроки створюються контейнером - залежності впроваджуються автоматично. Крок може бути класом, замиканням чи рядком Class:параметр.

Де пайплайн доречний:

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

Переваги:

  • кожен крок - маленький клас з однією відповідальністю, тестується окремо;
  • порядок і склад кроків видно в одному місці;
  • новий крок не змінює наявні.

Корисні можливості:

  • ->via('process') - інша назва методу кроків замість handle;
  • ->then(fn ($order) => ...) - фінальна дія після всіх кроків;
  • транзакція навколо всього пайплайну - Pipeline::send(...)->withinTransaction() (у нових версіях Laravel) або DB::transaction навколо виклику;
  • умовні кроки - збирати масив кроків за умовами перед through().

Пастки:

  • приховані залежності між кроками: крок 4 очікує, що крок 2 заповнив поле. Порядок стає неявним контрактом - його варто документувати чи перевіряти;
  • мутація спільного об'єкта: кроки змінюють той самий об'єкт - важко зрозуміти, хто що змінив. Для складних процесів - незмінні об'єкти, які кожен крок повертає новими;
  • надмірність: три рядки лінійного коду не потребують пайплайну з трьох класів;
  • обробка помилок: виняток у середині зупиняє весь процес - має бути зрозуміло, що відбувається з уже зробленими змінами.

Докладніше в документації: Laravel: Pipeline

Проблема: всередині транзакції відправляються події чи ставляться задачі в чергу, але транзакція ще не закомічена.

DB::transaction(function () use ($data) {
    $order = Order::create($data);
    OrderPlaced::dispatch($order);              // слухачі виконуються ЗАРАЗ
    SendInvoice::dispatch($order);              // воркер може взяти задачу ДО коміту
    $this->payments->charge($order);            // а тут виняток - усе відкочено
});

Що піде не так:

  • лист «Ваше замовлення оформлено» про замовлення, якого немає - транзакцію відкочено, а лист уже надіслано;
  • воркер не знаходить запис: задача з черги стартує до коміту, Order::find() повертає null - ModelNotFoundException;
  • зовнішні системи отримали дані, яких у базі немає (вебхук, синхронізація з CRM).

Інструменти Laravel:

  • ShouldDispatchAfterCommit на класі події - подія відправляється лише після успішного коміту (і зовсім не відправляється при відкоті);
  • ShouldHandleEventsAfterCommit на слухачі - слухач виконується після коміту;
  • afterCommit() на джобі чи after_commit => true у конфігурації з'єднання черги - задача потрапляє в чергу лише після коміту;
  • DB::afterCommit(fn () => ...) - будь-яка дія після коміту поточної транзакції.
final class OrderPlaced implements ShouldDispatchAfterCommit { /* ... */ }

SendInvoice::dispatch($order)->afterCommit();

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

  1. в тій самій транзакції, що й зміна даних, записати повідомлення в таблицю outbox;
  2. окремий процес читає outbox і відправляє повідомлення (в чергу, брокер, вебхук), позначаючи відправлені;
  3. споживачі - ідемпотентні, бо повідомлення може прийти двічі.

Так зміна даних і факт «треба повідомити» атомарні - або обидва є, або жодного.

Інші правила:

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

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

Мультиорендність (multi-tenancy) - один застосунок обслуговує багато клієнтів-орендарів (компанії, школи, магазини), і дані кожного мають бути ізольовані від інших.

Три основні моделі зберігання:

1. Спільна база, колонка tenant_id:

#[ScopedBy(TenantScope::class)]
class Project extends Model {}

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where('tenant_id', app(CurrentTenant::class)->id);
    }
}
  • плюси: просто, дешево, одна міграція на всіх, легко робити звіти по всіх орендарях;
  • мінуси: ізоляція тримається на коді - один забутий фільтр (сирий запит, withoutGlobalScopes, джоба без контексту орендаря) - і дані одного клієнта бачить інший. «Галасливий сусід» навантажує базу для всіх.

Посилення: Row-Level Security у PostgreSQL - політика на рівні бази (USING (tenant_id = current_setting('app.tenant_id')::int)): навіть помилка в застосунку не поверне чужих рядків. Застосунок встановлює змінну сесії бази на кожен запит.

2. Окрема схема на орендаря (PostgreSQL schemas): ізоляція сильніша, бекап і відновлення окремого клієнта простіші, але міграції треба проганяти по всіх схемах, а тисячі схем ускладнюють обслуговування.

3. Окрема база на орендаря: найсильніша ізоляція, окремі ресурси, можливість розмістити великого клієнта на окремому сервері чи в іншому регіоні (вимоги до зберігання даних). Ціна - складна інфраструктура, міграції по сотнях баз, з'єднання, крос-орендна аналітика.

Що треба вирішити незалежно від моделі:

  • визначення поточного орендаря: піддомен (acme.app.com), домен, шлях, обраний у сесії - і перевірка, що користувач належить орендарю;
  • контекст у фонових процесах: джоби, команди, планувальник не мають запиту - ідентифікатор орендаря передається явно в задачу й відновлюється перед виконанням;
  • кеш, файли, черги, пошук - ключі й шляхи з префіксом орендаря, інакше витік через кеш;
  • унікальність - unique(['tenant_id', 'email']), а не глобальна;
  • тести ізоляції: для кожного ресурсу - «орендар A не бачить даних орендаря B».

Як обрати: більшість SaaS починає зі спільної бази з tenant_id (+ RLS для критичних даних) і переходить до окремих баз лише для великих клієнтів чи регуляторних вимог. Пакети stancl/tenancy і spatie/laravel-multitenancy реалізують обидва підходи.

Докладніше в документації: PostgreSQL: політики безпеки рядків

Звичайний PHP-FPM: на кожен запит фреймворк завантажується з нуля і після відповіді все знищується. Будь-який стан живе рівно один запит - це «безкоштовна» ізоляція.

Octane (FrankenPHP, Swoole, RoadRunner) завантажує застосунок один раз і обробляє ним тисячі запитів. Звідси приріст швидкості - і нові класи помилок: стан переживає запит.

Що ламається:

1. Сінглтони, що захопили дані запиту:

// погано: сінглтон створено при першому запиті - і він назавжди тримає ТОГО користувача
$this->app->singleton(CartService::class, fn ($app) => new CartService($app['request']->user()));

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

2. Статичні властивості й статичні кеші (static $cache = []) накопичують дані між запитами - витоки пам'яті й даних між користувачами.

3. Впровадження контейнера чи запиту в сінглтон - сінглтон отримає контейнер першого запиту. Документація Octane радить замикання-резолвери (fn () => app('request')) замість прямого впровадження.

4. Витоки пам'яті: масиви, що лише ростуть (логування в статичний масив, реєстрація слухачів на кожен запит). Octane перезапускає воркери після N запитів (--max-requests), але це страховка, а не рішення.

5. З'єднання з базою й сторонніми сервісами живуть довго - потрібна обробка розірваних з'єднань.

Що варто переглянути в коді:

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

Нові можливості:

  • кешування на рівні воркера (Octane::table(), кеш у пам'яті) для дуже гарячих даних;
  • паралельні задачі (Octane::concurrently() на Swoole) чи Concurrency::run();
  • фонові «тікери» на Swoole.

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

Чи потрібен Octane: якщо основний час відповіді - запити до бази й сторонні API, Octane дасть менше, ніж оптимізація запитів. Він найкорисніший, коли значну частину часу займає завантаження фреймворку.

Докладніше в документації: Laravel Octane: впровадження залежностей

CQRS (Command Query Responsibility Segregation) - розділення моделі на дві: одна для змін (команди), інша для читання (запити).

Чому одна модель часто незручна для обох задач:

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

Одна модель на обидві задачі або обростає зв'язками й N+1 для читання, або втрачає виразність для запису.

Рівні CQRS - від легкого до радикального:

1. Розділення в коді - найпоширеніше й найкорисніше:

// команди: змінюють стан через агрегати
final class PlaceOrder { public function handle(PlaceOrderData $data): OrderId { /* ... */ } }

// запити: окремі класи, що читають напряму, без моделей домену
final class OrderListQuery
{
    public function get(int $customerId): Collection
    {
        return DB::table('orders')
            ->join('customers', ...)
            ->select(['orders.id', 'customers.name', 'orders.status', ...])
            ->where('orders.customer_id', $customerId)
            ->get();
    }
}

Читання не обмежене формою агрегатів - оптимізований SQL під екран.

2. Окремі моделі читання (read models) - денормалізовані таблиці чи проєкції, що оновлюються подіями: таблиця order_summaries з усім потрібним для списку.

3. Окремі сховища - запис у реляційну базу, читання з Elasticsearch/Meilisearch чи окремої репліки.

Коли CQRS виправданий:

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

Коли шкодить: у простих CRUD-застосунках - подвоєння коду без вигоди. Фаулер прямо попереджає: для більшості систем CQRS додає ризику й складності.

Ціна окремих моделей читання:

  • кінцева узгодженість: користувач зберіг зміну, а список ще показує старе. Інтерфейс має це враховувати;
  • синхронізація: проєкції, що не оновилися через помилку, треба вміти перебудувати;
  • більше рухомих частин.

Практична порада: почати з рівня 1 (окремі класи-запити) - він дає більшість користі майже без ціни.

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

Event sourcing - замість поточного стану зберігається послідовність подій, що до нього призвели. Стан обчислюється «програванням» подій.

Звичайно:           accounts: id=7, balance=150

Event sourcing:     AccountOpened(id=7)
                    MoneyDeposited(7, 200)
                    MoneyWithdrawn(7, 80)
                    MoneyDeposited(7, 30)
                    → balance = 150

Події лише додаються - ніколи не змінюються й не видаляються. Виправлення - нова подія (DepositCorrected), а не редагування старої.

Переваги:

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

Ціна - і вона значна:

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

Коли варто: домен, де історія змін - сама цінність (рахунки, облік, бронювання, системи з аудитом), або де потрібні складні часові запити.

Коли не варто: CRUD, контентні сайти, більшість типових вебзастосунків. Журнал аудиту (spatie/laravel-activitylog) чи таблиця історії змін дають частину переваг без перебудови архітектури.

У Laravel-екосистемі є готові пакети (наприклад, spatie/laravel-event-sourcing), але вони не знімають концептуальної складності.

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

Стратегічне проєктування в DDD починається не з коду, а з питання: які частини бізнесу справді важливі і куди вкладати найкращі сили.

Предметну область ділять на піддомени трьох типів:

1. Основний (core) піддомен - те, що відрізняє бізнес від конкурентів і заробляє гроші. Для сервісу пошуку роботи - алгоритм підбору вакансій і якість даних про них; для маркетплейсу - ціноутворення й рекомендації.

  • сюди - найкращі розробники і найретельніше моделювання;
  • писати самим: готове рішення означає мати те саме, що й конкуренти;
  • тут DDD приносить найбільше користі.

2. Допоміжний (supporting) піддомен - потрібен бізнесу, специфічний для нього, але не дає конкурентної переваги: внутрішня адмінка модерації, звіти для менеджерів.

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

3. Загальний (generic) піддомен - однаковий для більшості бізнесів: автентифікація, платежі, розсилки, бухгалтерія, пошук, зберігання файлів.

  • купувати чи брати готове: Stripe/LiqPay, Mailgun, Meilisearch, Laravel Fortify, готові CRM;
  • писати самим - марнування ресурсів і ризик зробити гірше, ніж готовий продукт.

Як це впливає на архітектуру:

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

Типові помилки:

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

Класифікація змінюється з часом: унікальна функція стає загальною, коли ринок наздоганяє; загальна може стати основною, якщо бізнес вирішує конкурувати саме в ній.

Для співбесіди: уміння пояснити, що в конкретному проєкті є основним піддоменом, - показник розуміння бізнесу, а не лише технологій.

Докладніше в документації: Azure: аналіз предметної області

DDD часто сприймають як набір тактичних патернів: репозиторії, фабрики, агрегати, value objects, шари. Перенесені механічно в Laravel-проєкт, вони дають багато коду й мало користі. Прагматичний підхід - брати ідеї, а не церемонії.

Що варто брати майже завжди:

  • єдина мова: назви моделей, методів, подій і енумів - зі словника бізнесу ($order->cancel(), OrderCancelled, OrderStatus::Refunded);
  • поведінка поруч з даними для важливих правил: методи на моделях замість $order->status = ... по всьому коду;
  • енуми й об'єкти-значення для грошей, статусів, періодів - через касти Eloquent;
  • доменні події для побічних ефектів;
  • actions/use cases - один клас на бізнес-операцію (PlaceOrder, RefundPayment): зрозуміла точка входу, легко тестувати;
  • модулі за предметними областями, а не за технічними шарами, коли проєкт росте:
app/Domain/Billing/{Models,Actions,Events,Enums}
app/Domain/Catalog/...
app/Domain/Shipping/...

Що варто брати лише для складного ядра:

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

Що зазвичай зайве в Laravel-проєкті:

  • репозиторії-обгортки над Eloquent «для чистоти» - дублюють Eloquent і не дають ізоляції;
  • окремі доменні класи + мапінг у Eloquent для простих сутностей - десятки класів перетворень;
  • повна гексагональна архітектура для CRUD-адмінки;
  • event sourcing без бізнес-потреби в історії.

Як вирішувати, де межа: спершу визначити основний піддомен (те, що приносить гроші й має складні правила). Там - більше моделювання й захисту інваріантів. Допоміжні й загальні частини - звичайний Laravel: моделі, Form Request, ресурси, Filament.

Ознаки, що складність виправдана:

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

Ознаки «DDD заради DDD»: більше коду інфраструктури, ніж бізнес-логіки; кожна проста зміна торкається п'яти шарів; нові розробники тижнями не розуміють, куди писати код.

Підсумок для співбесіди: DDD - насамперед про розуміння предметної області й спільну мову з бізнесом. Тактичні патерни - інструменти, які вмикають там, де складність домену цього вимагає.

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

Сага - спосіб виконати бізнес-операцію, що охоплює кілька сервісів, без розподіленої транзакції. Операція розбивається на послідовність локальних транзакцій, кожна в своєму сервісі. Якщо крок не вдається, виконуються компенсуючі дії для вже виконаних кроків.

Приклад - оформлення замовлення:

1. Замовлення: створити (статус pending)       компенсація: скасувати
2. Склад: зарезервувати товар                   компенсація: зняти резерв
3. Платежі: списати кошти                       компенсація: повернути кошти
4. Замовлення: підтвердити

Якщо оплата не пройшла (крок 3) - знімається резерв (компенсація 2) і скасовується замовлення (компенсація 1).

Компенсація - не відкат. Інші частини системи вже могли побачити проміжні стани (резерв товару, замовлення pending). Компенсація - нова бізнес-операція («повернути кошти»), а не магічне повернення в минуле.

Хореографія - кожен сервіс реагує на події інших:

OrderCreated → Склад резервує → StockReserved → Платежі списують → PaymentCompleted → Замовлення підтверджує
  • плюси: немає центрального координатора, сервіси слабо зв'язані, просто для 2-3 кроків;
  • мінуси: логіку процесу ніде не видно цілком - вона розподілена по слухачах; важко відповісти «на якому кроці зараз замовлення?»; ризик циклічних залежностей подій.

Оркестрація - окремий оркестратор (сервіс чи об'єкт процесу) керує кроками, надсилаючи команди й чекаючи відповідей:

Оркестратор: «Склад, зарезервуй» → OK → «Платежі, спиши» → помилка → «Склад, зніми резерв» → «Замовлення, скасуй»
  • плюси: процес описаний в одному місці, стан саги зберігається явно, легко моніторити й змінювати;
  • мінуси: оркестратор - додатковий компонент і ризик «знати забагато».

Що обов'язково для саг:

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

Інструменти: у межах Laravel - ланцюжки й пакети джоб (Bus::chain, Bus::batch) і явна модель стану процесу; для складних процесів - рушії робочих процесів (Temporal тощо).

Правило вибору: хореографія - для простих процесів з кількома кроками; оркестрація - для складних, де важлива видимість і керування процесом.

Докладніше в документації: Microservices.io: Saga

Двофазний коміт (2PC) - протокол атомарної транзакції на кількох учасниках (базах, сервісах) під керуванням координатора.

Фаза 1 - підготовка (prepare): координатор питає кожного учасника «чи можеш закомітити?». Учасник виконує зміни, блокує ресурси, записує все необхідне для коміту й відповідає «готовий» чи «ні».

Фаза 2 - коміт: якщо всі відповіли «готовий», координатор надсилає «комітьте»; якщо хоч один «ні» - «відкотіть».

Результат - усі учасники або закомітили, або відкотили.

Чому в мікросервісах його уникають:

1. Блокування й доступність. Між фазами учасники тримають блокування і чекають рішення координатора. Якщо координатор упав після фази 1, учасники в стані «готовий» не можуть ні закомітити, ні відкотити самостійно - ресурси заблоковані, доки координатор не відновиться. Одна несправна ланка зупиняє всіх.

2. Затримки. Кожна транзакція - щонайменше два мережеві обміни з кожним учасником, а блокування тримаються весь цей час. Пропускна здатність падає.

3. Зв'язаність. Усі учасники мають підтримувати той самий протокол (XA) і бути доступні одночасно. Це суперечить ідеї незалежних сервісів з різними технологіями й сховищами.

4. Підтримка. Брокери повідомлень, NoSQL-бази, зовнішні API (платіжні системи) зазвичай не беруть участі в 2PC. Транзакцію «база + Kafka + Stripe» двофазним комітом не зробити.

5. Теорема CAP на практиці: при розриві мережі 2PC жертвує доступністю заради узгодженості - для більшості вебсистем це неприйнятна ціна.

Що використовують натомість:

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

Де 2PC (і схожі протоколи) все ж живуть: усередині розподілених баз даних (Spanner, CockroachDB використовують його варіанти з консенсусом), у класичних корпоративних системах з XA-транзакціями між кількома базами одного постачальника. На рівні архітектури вебсервісів - майже ніколи.

Для співбесіди: важливо не лише описати фази, а й пояснити проблему блокування при збої координатора - саме вона робить 2PC непридатним для слабко зв'язаних систем.

Докладніше в документації: Patterns of Distributed Systems: Two-Phase Commit

Питання з реальних технічних співбесід - 100 питань у 7 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 35 Middle 35 Senior 30

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії