Питання на співбесіді з Архітектура
Питання з реальних співбесід з відповідями: 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 дає змогу одному класу бути і контролером, і джобою, і командою - зручно, але додає «магії».
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 + кеш);
- модульний моноліт, де модуль не повинен віддавати свої моделі назовні.
Для співбесіди: важливо показати розуміння компромісу, а не «завжди» чи «ніколи».
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 вводять там, де потрібен контракт між частинами коду.
Стандартна структура 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 дають генератори й автозавантаження модулів, але суть - у дисципліні меж, а не в структурі папок.
Фасад - статичний «інтерфейс» до об'єкта з контейнера: 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 межі агрегату - домовленість, а не обмеження мови: моделі позицій усе одно можна змінити напряму. Допомагають код-рев'ю, методи на корені й те, що контролери працюють лише з коренем.
Велика спокуса - зробити агрегат «як в реальному світі»: клієнт з усіма замовленнями, замовлення з товарами, товари з категоріями. Вон Вернон у серії «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-підхід у легкому варіанті).
Головне питання: яку проблему вирішує шар? Якщо відповідь «так прийнято», - його, ймовірно, не варто додавати.
Обидва - «сервіси», але на різних рівнях і з різною відповідальністю.
Доменний сервіс - бізнес-логіка, що не належить природно жодній сутності чи об'єкту-значенню. Зазвичай - операція, що охоплює кілька агрегатів чи потребує зовнішніх правил:
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 (запобіжник) - обгортка над викликом, що відстежує помилки й перестає надсилати запити, коли сервіс явно несправний. Три стани:
- закритий (closed) - запити проходять, помилки рахуються;
- відкритий (open) - після N помилок поспіль чи частки помилок понад поріг виклики одразу відхиляються без звернення до сервісу. Застосунок отримує миттєву помилку й використовує запасний варіант;
- напіввідкритий (half-open) - після паузи пропускається кілька пробних запитів. Успіх - назад у закритий стан; помилка - знову відкритий.
Що це дає:
- швидкий збій замість очікування тайм-ауту - потоки й воркери застосунку не блокуються;
- сервіс, що відновлюється, отримує паузу від лавини запитів;
- запасна поведінка: показати кешовані дані, приховати блок рекомендацій, поставити операцію в чергу на пізніше.
Bulkhead (перегородка) - ізоляція ресурсів, щоб проблема в одній частині не «затопила» всю систему (назва - від перегородок у корпусі корабля):
- окремі пули з'єднань і воркерів для різних залежностей: повільний сервіс звітів не вичерпує з'єднання, потрібні для оплат;
- окремі черги для різних типів джоб: тисяча повільних генерацій PDF не блокує відправку листів підтвердження;
- ліміти паралельності на виклики конкретного сервісу.
У Laravel-застосунку:
- окремі черги й супервізори Horizon для різних типів робіт - найпростіший bulkhead;
- middleware джоб
ThrottlesExceptions- фактично запобіжник для джоб: після кількох винятків джоби відкладаються на час замість безперервних спроб; - запобіжник для HTTP реалізують через кеш (лічильник помилок і прапорець «відкрито» в Redis) чи бібліотеки;
- короткі тайм-аути - основа: без них жоден з патернів не допоможе.
Що важливо налаштувати: пороги (скільки помилок і за який час), тривалість відкритого стану, що вважається помилкою (тайм-аути й 5xx - так, 404 і 422 - ні), і моніторинг: запобіжник, що відкрився, - подія, про яку має дізнатися команда.
Принцип «база даних на сервіс»: кожен сервіс сам володіє своїми даними. Інші сервіси не читають і не пишуть його таблиці напряму - лише через його 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії