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

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

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

100 питань

Action-клас - клас, що виконує одну бізнес-операцію і має один публічний метод (handle() чи __invoke()):

final class PlaceOrder
{
    public function __construct(
        private InventoryService $inventory,
        private PaymentGateway $payments,
    ) {}

    public function handle(User $user, array $data): Order
    {
        return DB::transaction(function () use ($user, $data) {
            $order = $user->orders()->create([...]);
            $this->inventory->reserve($order);
            $this->payments->authorize($order);
            OrderPlaced::dispatch($order);

            return $order;
        });
    }
}

Сервіс - клас, що групує кілька операцій навколо однієї області: OrderService з place(), cancel(), refund(), ship().

Переваги action-класів:

  • назва = бізнес-операція: PlaceOrder, CancelSubscription, InviteTeamMember - структура папки app/Actions читається як перелік того, що вміє застосунок;
  • маленькі й сфокусовані - не розростаються до «сервісу на 2000 рядків», який з часом обростає всім, що стосується замовлень;
  • точні залежності: кожен action впроваджує лише те, що йому потрібно;
  • виклик звідусіль: контролер, команда Artisan, джоба, Livewire-компонент, тест - та сама дія;
  • легко тестувати: одна операція - один набір тестів.

Коли сервіс доречніший:

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

Практики, що добре працюють разом:

  • action приймає перевірені дані (масив з validated() чи DTO), а не Request - щоб працювати поза HTTP;
  • транзакція - межа action-а;
  • побічні реакції - через події, а не прямими викликами в action;
  • action може викликати інші actions, але глибокі ланцюжки - сигнал, що межі обрано невдало.

Цей підхід використовують офіційні стартові набори й Fortify (app/Actions/Fortify/CreateNewUser). Пакет lorisleiva/laravel-actions дає змогу одному класу бути і контролером, і джобою, і командою - зручно, але додає «магії».

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

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

Типова реалізація в Laravel-проєктах:

interface OrderRepository
{
    public function find(int $id): ?Order;
    public function forUser(User $user): Collection;
    public function save(Order $order): void;
}

final class EloquentOrderRepository implements OrderRepository { /* ... */ }

Аргументи «за»:

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

Аргументи «проти» в контексті Laravel:

  • Eloquent - уже Active Record: модель сама вміє зберігатися й будувати запити. Репозиторій, що повертає ті самі моделі Eloquent, не приховує сховища: модель усе одно має save(), delete() і ледаче завантаження зв'язків;
  • «дірява» абстракція: методи на кшталт findByStatusAndDateAndUserWithRelations() множаться, бо Eloquent-будівник запитів гнучкіший за будь-який інтерфейс;
  • заміна бази - рідкісний сценарій, а заміна ORM у Laravel-проєкті - ще рідший;
  • тестування з базою в Laravel дешеве (SQLite в пам'яті, транзакції, фабрики), тож аргумент «тести без бази» слабшає;
  • більше коду: інтерфейс + реалізація + реєстрація в контейнері на кожну модель.

Компромісні рішення, що дають більшу частину користі:

  • scopes і власні будівники запитів (#[UseEloquentBuilder]) - повторювані умови в одному місці;
  • query-класи для складних запитів і звітів (MonthlyRevenueQuery);
  • action-класи - бізнес-операції, що працюють з моделями напряму.

Коли репозиторій справді виправданий:

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

Для співбесіди: важливо показати розуміння компромісу, а не «завжди» чи «ніколи».

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

DTO (Data Transfer Object) - простий об'єкт для передачі даних між шарами застосунку: типізовані поля без бізнес-поведінки.

Проблема масивів:

public function handle(array $data): Order
{
    $data['customer_id'];   // а чи є такий ключ? який тип? як він називається - customerId?
}

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

DTO на сучасному PHP:

final readonly class PlaceOrderData
{
    /** @param list<OrderLineData> $lines */
    public function __construct(
        public int $customerId,
        public array $lines,
        public ?string $comment = null,
        public DeliveryMethod $delivery = DeliveryMethod::Courier,
    ) {}

    public static function fromRequest(StoreOrderRequest $request): self
    {
        return new self(
            customerId: $request->user()->id,
            lines: array_map(OrderLineData::fromArray(...), $request->validated('items')),
            comment: $request->validated('comment'),
            delivery: DeliveryMethod::from($request->validated('delivery')),
        );
    }
}

Що дає DTO:

  • явний контракт: з сигнатури handle(PlaceOrderData $data) видно, що потрібно операції;
  • типи й підказки IDE, перевірка статичним аналізом;
  • незмінність (readonly): дані не зміняться непомітно по дорозі;
  • незалежність від джерела: та сама дія викликається з HTTP (fromRequest), команди Artisan, імпорту CSV, тестів;
  • енуми й об'єкти-значення замість рядків.

Де DTO корисні найбільше:

  • вхід бізнес-операцій (action-класи, сервіси);
  • відповіді сторонніх API - перетворити JSON на типізовані об'єкти одразу на межі, а не тягнути масиви по коду;
  • межі модулів - модуль віддає DTO, а не свої моделі Eloquent.

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

  • readonly-класи PHP 8.2+ з властивостями конструктора - достатньо для більшості випадків;
  • spatie/laravel-data - DTO з вбудованою валідацією, перетворенням з/у масив, Request, моделі, генерацією TypeScript-типів;
  • Form Request + метод toDto().

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

Докладніше в документації: PHP: readonly-класи

Стандартна структура Laravel групує код за технічним типом: усі контролери в Http/Controllers, усі моделі в Models. У великому застосунку зміна однієї функції (наприклад, оплати) зачіпає десяток папок, а межі між частинами системи не видно.

Модульний моноліт групує код за предметними областями, лишаючись одним застосунком з одним деплоєм:

app/
  Modules/
    Billing/
      Actions/ Models/ Http/ Events/ Listeners/ Jobs/
      BillingServiceProvider.php
      routes.php
    Catalog/
    Orders/
    Shared/        ← спільні об'єкти-значення, базові класи

Кожен модуль:

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

Як модулі взаємодіють:

  • виклик публічного контракту: Orders викликає Billing\Contracts\ChargesCustomers, а не лізе в моделі Billing;
  • події: Orders публікує OrderPlaced, Billing і Inventory підписуються - без прямої залежності;
  • ідентифікатори замість моделей на межах: модуль передає customerId, а не модель Customer з іншого модуля.

Як утримати межі - найскладніша частина:

  • архітектурні тести (Pest arch()): «код з Modules\Catalog не використовує Modules\Billing\Models»;
  • зв'язки Eloquent між модулями - найчастіший спосіб непомітно зламати межі. Їх обмежують чи замінюють запитами через публічний інтерфейс модуля;
  • рев'ю коду з увагою до нових залежностей між модулями.

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

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

Інструменти: пакети на кшталт nwidart/laravel-modules чи internachi/modular дають генератори й автозавантаження модулів, але суть - у дисципліні меж, а не в структурі папок.

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

Фасад - статичний «інтерфейс» до об'єкта з контейнера: Cache::get(), Mail::to(), Http::get(). За кулісами Cache звертається до контейнера й викликає метод справжнього об'єкта.

// фасад
final class ExchangeRates
{
    public function rate(string $currency): float
    {
        return Cache::remember("rate:$currency", 3600, fn () => Http::get(...)->json('rate'));
    }
}

// впровадження залежностей
final class ExchangeRates
{
    public function __construct(
        private CacheRepository $cache,
        private HttpFactory $http,
    ) {}
}

Тестування - обидва способи працюють. Попри статичний синтаксис, фасади Laravel підмінюються:

Cache::shouldReceive('remember')->once()->andReturn(41.5);
Http::fake(['bank.example/*' => Http::response(['rate' => 41.5])]);
Mail::fake();
Queue::fake();

Це головна відмінність від справжніх статичних класів: фасад - не глобальний стан, а точка доступу до контейнера.

Аргументи на користь впровадження залежностей:

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

Аргументи на користь фасадів:

  • лаконічність - особливо в контролерах, маршрутах, Blade, коді, що не перевикористовується;
  • готові фейки (Mail::fake(), Storage::fake(), Bus::fake()) з виразними перевірками;
  • ідіоматичність: документація й більшість Laravel-коду використовують фасади.

Практичний підхід, поширений у командах:

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

Real-time фасади (use Facades\App\Services\Rates;) дають статичний доступ до будь-якого класу - зручно, але ще більше ховають залежності.

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

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

Приклад: замовлення (Order - корінь) з позиціями (OrderLine). Позицію не змінюють напряму - лише через замовлення:

$order->addLine($product, quantity: 2);
$order->changeQuantity($lineId, 5);
$order->removeLine($lineId);

Навіщо - інваріанти. Інваріант - бізнес-правило, яке має виконуватися завжди:

  • сума замовлення дорівнює сумі позицій;
  • у замовленні не більше 50 позицій;
  • оплачене замовлення не можна змінювати;
  • загальна знижка не перевищує 30%.

Якщо позиції можна змінювати напряму (OrderLine::find($id)->update(...)), правила перевіряються в кількох місцях - або не перевіряються. Корінь агрегату - єдине місце, де їх контролюють:

public function addLine(Product $product, int $quantity): void
{
    if ($this->isPaid()) {
        throw new OrderIsLocked($this);
    }
    if ($this->lines->count() >= 50) {
        throw new TooManyLines($this);
    }

    $this->lines->push(new OrderLine(...));
    $this->recalculateTotal();
}

Межа агрегату - межа транзакції. Агрегат зберігається й змінюється атомарно, в одній транзакції. Зміна двох агрегатів в одній транзакції - сигнал перевірити межі.

Правила проєктування (Вон Вернон):

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

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

У Laravel з Eloquent межі агрегату - домовленість, а не обмеження мови: моделі позицій усе одно можна змінити напряму. Допомагають код-рев'ю, методи на корені й те, що контролери працюють лише з коренем.

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

Велика спокуса - зробити агрегат «як в реальному світі»: клієнт з усіма замовленнями, замовлення з товарами, товари з категоріями. Вон Вернон у серії «Effective Aggregate Design» сформулював правила, чому так робити не варто.

Правило 1. Агрегат - для інваріантів, а не для зручності навігації. До агрегату входить лише те, що потрібно для перевірки правил, які мають виконуватися миттєво й разом. Якщо правило «замовлення має позиції з сумою, що дорівнює total» - позиції входять. Якщо «клієнт має не більше 3 неоплачених замовлень» - це не обов'язково причина робити всі замовлення частиною агрегату клієнта.

Правило 2. Маленькі агрегати. Великий агрегат:

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

Правило 3. Посилання на інші агрегати - за ідентифікатором:

// погано: агрегат тримає інший агрегат
class Order { private Customer $customer; }

// добре
class Order { private int $customerId; }

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

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

OrderPlaced → слухач (окрема транзакція) → оновити лічильник у Customer

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

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

Практика в Laravel: зв'язки Eloquent ($order->customer) зручні для читання, але змінювати інші агрегати через них у методах кореня не варто. Читання - через зв'язки й запити; зміни - через корінь відповідного агрегату.

Докладніше в документації: Vaughn Vernon: Effective Aggregate Design

Репозиторій у DDD - колекція агрегатів в пам'яті з погляду домену: «дай замовлення за id», «збережи замовлення». Домен не знає, як саме дані зберігаються - SQL, документна база, зовнішній API.

interface OrderRepository
{
    public function find(OrderId $id): ?Order;
    public function save(Order $order): void;
    public function nextIdentity(): OrderId;
}

Конфлікт з Eloquent. Eloquent - патерн Active Record: модель сама знає, як себе зберегти ($order->save()), і є водночас доменним об'єктом і рядком таблиці. Класичний DDD передбачає Data Mapper: доменні об'єкти без знань про базу, а окремий шар (як Doctrine ORM) переносить їх у таблиці.

Варіанти на практиці:

1. Eloquent-моделі як доменні об'єкти, без репозиторіїв. Найпоширеніший і чесний для більшості Laravel-проєктів: поведінка в моделях, запити - в query scopes і окремих класах-запитах. Репозиторій-обгортка над Eloquent, що лише проксіює find/save, додає шар без користі.

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

3. Чисті доменні об'єкти + Eloquent як шар зберігання. Домен - звичайні PHP-класи, репозиторій перетворює їх у Eloquent-моделі й назад. Максимальна ізоляція, але багато коду перетворень і втрата зручностей Laravel (зв'язки, касти, події моделей).

4. Doctrine ORM замість Eloquent - справжній Data Mapper, якщо DDD - ключова вимога проєкту.

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

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

Головне питання: яку проблему вирішує шар? Якщо відповідь «так прийнято», - його, ймовірно, не варто додавати.

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

Обидва - «сервіси», але на різних рівнях і з різною відповідальністю.

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

final class TransferPolicy
{
    public function transfer(Account $from, Account $to, Money $amount): void
    {
        if ($from->isFrozen() || $to->isFrozen()) {
            throw new AccountFrozen();
        }

        $from->withdraw($amount);
        $to->deposit($amount);
    }
}

Ознаки доменного сервісу:

  • назва з єдиної мови (PricingPolicy, TransferPolicy, ShippingCostCalculator);
  • без інфраструктури: не знає про HTTP, базу, черги, пошту;
  • без стану - лише операції над доменними об'єктами.

Сервіс застосунку (application service, use case) - координує виконання сценарію: отримує вхідні дані, завантажує агрегати, викликає доменну логіку, зберігає результат, запускає побічні ефекти.

final class TransferMoney
{
    public function __construct(
        private AccountRepository $accounts,
        private TransferPolicy $policy,
    ) {}

    public function handle(int $fromId, int $toId, Money $amount): void
    {
        DB::transaction(function () use ($fromId, $toId, $amount) {
            $from = $this->accounts->lockForUpdate($fromId);
            $to = $this->accounts->lockForUpdate($toId);

            $this->policy->transfer($from, $to, $amount);

            $this->accounts->save($from);
            $this->accounts->save($to);
        });

        MoneyTransferred::dispatch($fromId, $toId, $amount);
    }
}

Ознаки сервісу застосунку:

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

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

У Laravel роль сервісу застосунку часто виконують actions (app/Actions/TransferMoney.php), job-класи чи тонкі контролери з Form Request. Типова помилка - «сервіс застосунку», в якому зосереджено всю бізнес-логіку, а моделі лишаються анемічними: тоді це вже не DDD, а транзакційний сценарій.

Докладніше в документації: Microsoft: проєктування DDD-орієнтованого мікросервісу

Антикорупційний шар (anti-corruption layer, ACL) - прошарок між вашою моделлю й чужою моделлю (зовнішній API, легасі-система, інший контекст), що перекладає між ними й не дає чужим поняттям «проникнути» у ваш домен.

Проблема без нього. Платіжний провайдер повертає {"txn_state": "SETTLED_PARTIAL", "amt_minor": 12550, "cur": 980}. Якщо ці назви й коди розходяться по коду застосунку, ваш домен починає «говорити мовою провайдера»:

  • перевірки if ($payment->txn_state === 'SETTLED_PARTIAL') у десятках місць;
  • зміна API провайдера чи перехід на іншого ламає весь застосунок;
  • модель провайдера (і її дивацтва) стає вашою моделлю.

З антикорупційним шаром:

final class LiqPayGateway implements PaymentGateway   // інтерфейс вашого домену
{
    public function status(PaymentId $id): PaymentStatus
    {
        $raw = $this->client->get("/transactions/{$id}");

        return match ($raw['txn_state']) {
            'SETTLED' => PaymentStatus::Paid,
            'SETTLED_PARTIAL' => PaymentStatus::PartiallyPaid,
            'REVERSED', 'CHARGEBACK' => PaymentStatus::Refunded,
            default => PaymentStatus::Pending,
        };
    }
}

Домен знає лише PaymentGateway і PaymentStatus. Усі перетворення - в одному класі.

Що робить шар:

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

Коли потрібен:

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

Ціна: додатковий код і ще один шар, який треба підтримувати. Для простої, стабільної інтеграції (одне поле з API) повноцінний ACL - зайвий; досить невеликого класу-адаптера.

Зв'язок з патернами: технічно ACL часто складається з адаптерів, фасадів і перетворювачів - але суть не в патернах, а в захисті моделі домену від чужих понять.

Докладніше в документації: Azure: патерн Anti-corruption Layer

За доставки «щонайменше один раз» те саме повідомлення може прийти кілька разів. Ідемпотентний споживач гарантує, що повторна обробка не має додаткового ефекту.

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

public function handle(OrderPaid $message): void
{
    DB::transaction(function () use ($message) {
        $inserted = DB::table('processed_messages')->insertOrIgnore([
            'message_id' => $message->id,
            'consumer' => 'loyalty.points',
            'processed_at' => now(),
        ]);

        if ($inserted === 0) {
            return;   // уже оброблено
        }

        LoyaltyAccount::forCustomer($message->customerId)->addPoints($message->points);
    });
}

Транзакція гарантує: або повідомлення позначено і бали нараховано, або ні те, ні інше. Окремий запис «оброблено» після дії (не в транзакції) залишає вікно для дубліката.

Підхід 2 - ідемпотентна операція за природою. Замість «додати 100 балів» - «встановити бали за замовлення 42 у 100»:

LoyaltyEntry::updateOrCreate(
    ['order_id' => $message->orderId],
    ['points' => $message->points],
);

Повтор перезаписує те саме значення.

Підхід 3 - перевірка стану. «Якщо замовлення вже позначено оплаченим - нічого не робити». Працює, якщо стан однозначно показує, що дію виконано.

Підхід 4 - ключ ідемпотентності в зовнішньому виклику. Якщо споживач викликає платіжну систему, передати ключ (наприклад, id повідомлення) - провайдер сам відкине повтор.

Складні випадки:

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

У Laravel для джоб у черзі: ShouldBeUnique запобігає дублікатам у черзі, але не замінює ідемпотентності обробника - джоба все одно може виконатися двічі після збою воркера.

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

Сильна узгодженість - після запису всі читання одразу бачать нове значення. Кінцева узгодженість (eventual consistency) - після запису різні частини системи якийсь час можуть бачити старе значення, але зрештою, якщо нових змін немає, усі побачать однакове.

Звідки береться:

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

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

Як жити з нею в інтерфейсі:

  • читати свої записи з основного джерела: після збереження профілю показувати дані з відповіді на збереження чи з основної бази, а не з репліки (у Laravel - 'sticky' => true для з'єднань з репліками);
  • оптимістичне оновлення: інтерфейс одразу показує зміну, не чекаючи, поки вона пройде всіма системами;
  • чесні стани: «Замовлення приймається», «Оплата обробляється» замість миттєвого «Готово», якщо підтвердження приходить пізніше;
  • оновлення в реальному часі: коли фонова обробка завершилася - подія через WebSocket оновлює екран.

Як жити з нею в бізнес-процесах:

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

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

Типова помилка: не помічати кінцевої узгодженості, доки користувачі не почнуть скаржитися на «зниклі» зміни після збереження.

Докладніше в документації: Martin Fowler: компроміси мікросервісів

Проблема подвійного запису. Сервіс має зробити дві речі: зберегти зміну в базі і опублікувати подію в брокер повідомлень. Це дві різні системи, і атомарно зробити обидві дії неможливо:

DB::transaction(fn () => $order->markPaid());
$broker->publish(new OrderPaid($order->id));   // а якщо брокер недоступний чи процес впав тут?
  • база оновилася, подія не опублікована - інші сервіси ніколи не дізнаються про оплату;
  • якщо спершу публікувати, а потім зберігати, - подія є, а зміна в базі відкотилася.

Transactional Outbox: подія записується в таблицю тієї ж бази, у тій самій транзакції, що й зміна даних:

DB::transaction(function () use ($order) {
    $order->markPaid();

    DB::table('outbox')->insert([
        'id' => (string) Str::uuid7(),
        'type' => 'order.paid',
        'payload' => json_encode(['order_id' => $order->id]),
        'created_at' => now(),
    ]);
});

Тепер зміна і запис про подію або збережені обидва, або жоден.

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

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

Що варто врахувати:

  • at-least-once: ретранслятор може опублікувати подію, впасти до позначки «опубліковано» і опублікувати знову. Споживачі мають бути ідемпотентними - ідентифікатор запису outbox стає ідентифікатором повідомлення;
  • порядок: публікувати в порядку створення, якщо споживачам важлива послідовність;
  • очищення: опубліковані записи видаляти чи архівувати;
  • моніторинг затримки: записи, що висять неопублікованими, - сигнал проблеми з брокером чи ретранслятором.

Аналог у межах Laravel-застосунку: джоба, поставлена в чергу database у тій самій транзакції, або afterCommit() / ShouldDispatchAfterCommit - щоб подія й джоба відправлялися лише після успішного коміту. Але afterCommit не захищає від падіння процесу між комітом і відправкою в зовнішню чергу - Outbox захищає.

Дзеркальний патерн на стороні споживача - Inbox: записувати отримані повідомлення в таблицю для дедуплікації й надійної обробки.

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

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

Circuit Breaker (запобіжник) - обгортка над викликом, що відстежує помилки й перестає надсилати запити, коли сервіс явно несправний. Три стани:

  1. закритий (closed) - запити проходять, помилки рахуються;
  2. відкритий (open) - після N помилок поспіль чи частки помилок понад поріг виклики одразу відхиляються без звернення до сервісу. Застосунок отримує миттєву помилку й використовує запасний варіант;
  3. напіввідкритий (half-open) - після паузи пропускається кілька пробних запитів. Успіх - назад у закритий стан; помилка - знову відкритий.

Що це дає:

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

Bulkhead (перегородка) - ізоляція ресурсів, щоб проблема в одній частині не «затопила» всю систему (назва - від перегородок у корпусі корабля):

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

У Laravel-застосунку:

  • окремі черги й супервізори Horizon для різних типів робіт - найпростіший bulkhead;
  • middleware джоб ThrottlesExceptions - фактично запобіжник для джоб: після кількох винятків джоби відкладаються на час замість безперервних спроб;
  • запобіжник для HTTP реалізують через кеш (лічильник помилок і прапорець «відкрито» в Redis) чи бібліотеки;
  • короткі тайм-аути - основа: без них жоден з патернів не допоможе.

Що важливо налаштувати: пороги (скільки помилок і за який час), тривалість відкритого стану, що вважається помилкою (тайм-аути й 5xx - так, 404 і 422 - ні), і моніторинг: запобіжник, що відкрився, - подія, про яку має дізнатися команда.

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

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

Чому не спільна база:

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

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

Як отримати чужі дані:

1. Запит до API власника - коли потрібні актуальні дані в момент запиту:

Замовлення → GET /customers/42 → Клієнти

Мінус - синхронна залежність і затримка.

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

Клієнти: CustomerRenamed → Замовлення оновлює customer_name у своїй таблиці

Швидке читання без залежності від доступності іншого сервісу, але дані кінцево узгоджені.

3. API-композиція / BFF - окремий шар збирає дані з кількох сервісів для екрана.

4. CQRS-проєкції - окрема модель читання, що агрегує події кількох сервісів для звітів і пошуку.

Складності, які з'являються:

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

Аналог у модульному моноліті: модуль не звертається до моделей і таблиць іншого модуля - лише через його публічний сервіс чи події. Це готує до можливого розділення без переписування.

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

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

Рівні
Junior 35 Middle 35 Senior 30

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