Архітектура: питання на співбесіді рівня Senior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
30 питань
Factory (фабричний метод, фабрика) ховає рішення, який саме об'єкт створити, і як.
Доречна, коли:
- конкретний клас залежить від даних під час виконання:
NotificationChannelFactory::for($user->preferred_channel); - створення потребує залежностей, яких не має код-споживач (клієнт API з налаштуваннями, токеном, ретраями);
- створення - нетривіальна послідовність, яку не хочеться повторювати.
Іменовані конструктори (Money::fromCents(1999), Period::lastMonth()) - найлегша форма фабрики, яка часто краща за складний конструктор.
Builder збирає складний об'єкт покроково, коли параметрів багато й більшість необов'язкові:
$query = User::query()
->where('active', true)
->whereHas('orders')
->orderBy('name')
->limit(20);
$mail = (new MailMessage)
->subject('Рахунок')
->line('Ваш рахунок готовий.')
->action('Переглянути', $url);
Доречний, коли конструктор з десятьма параметрами нечитабельний, частина з них взаємозалежна, а об'єкт треба валідувати як ціле перед створенням. У Laravel так влаштовані Query Builder, MailMessage, Http::withHeaders()->retry()->get().
Коли це зайве:
- фабрика, що просто викликає
newбез жодного рішення, - додатковий файл без користі; - Builder для об'єкта з трьома обов'язковими полями - іменовані аргументи PHP 8 вирішують ту саму задачу:
new Invoice(number: ..., total: ..., dueDate: ...); - контейнер Laravel уже є фабрикою для більшості сервісів: автовпровадження й прив'язки часто замінюють ручні фабрики.
Правило: патерн має прибирати складність, яку ви вже маєте, а не складність, що «може з'явитися». Ввести фабрику, коли з'явився другий варіант, легше, ніж підтримувати абстракцію, якої ніколи не знадобилося.
LSP («L» у SOLID): об'єкт підтипу має бути можливо підставити скрізь, де очікується базовий тип, без зміни правильності програми. Нащадок має виконувати контракт предка, а не лише мати ті самі методи.
Класичне порушення - квадрат і прямокутник:
class Rectangle
{
public function setWidth(int $w): void { $this->width = $w; }
public function setHeight(int $h): void { $this->height = $h; }
public function area(): int { return $this->width * $this->height; }
}
class Square extends Rectangle
{
public function setWidth(int $w): void { $this->width = $this->height = $w; }
public function setHeight(int $h): void { $this->width = $this->height = $h; }
}
function stretch(Rectangle $r): void
{
$r->setWidth(5);
$r->setHeight(2);
assert($r->area() === 10); // для Square - 4
}
Математично квадрат - прямокутник, але поведінково ні: незалежна зміна сторін - частина контракту Rectangle.
Як порушують у реальному коді:
- Метод нащадка кидає
NotImplementedExceptionчи нічого не робить:ReadOnlyRepository extends Repositoryзsave(), що кидає виняток. - Посилені вимоги до вхідних даних: предок приймає будь-який рядок, нащадок - лише непорожній.
- Послаблені гарантії результату: предок завжди повертає об'єкт, нащадок - інколи
null. - Нові винятки, яких клієнти базового типу не очікують і не ловлять.
- Перевірка типу в коді клієнта:
if ($shape instanceof Square)- ознака, що підстановка не працює.
Що робити: якщо нащадок не може виконати контракт, він не має бути нащадком. Розділити інтерфейси (ReadableRepository, WritableRepository - принцип розділення інтерфейсів), використати композицію або незмінні об'єкти (незмінний квадрат цілком може бути незмінним прямокутником).
PHP частково допомагає: перевизначений метод не може звузити типи параметрів чи розширити тип повернення (контраваріантність і коваріантність перевіряються). Але поведінковий контракт рушій не перевіряє - лише тести й увага.
Chain of Responsibility (Ланцюжок обов'язків) - запит проходить послідовністю обробників. Кожен вирішує: обробити запит і зупинити ланцюжок, або щось зробити й передати далі.
Middleware Laravel - саме такий ланцюжок:
final class EnsureUserIsSubscribed
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()?->subscribed()) {
return redirect()->route('billing'); // зупинити ланцюжок
}
$response = $next($request); // передати далі
$response->headers->set('X-Plan', $request->user()->plan); // після обробки
return $response;
}
}
$next($request)- виклик наступного обробника; код до нього виконується на шляху запиту, після - на шляху відповіді;- повернення відповіді без
$next- ланцюжок зупиняється (автентифікація, CSRF, обмеження частоти, режим обслуговування); - обробники не знають один про одного - лише про
$next.
Під капотом - Illuminate\Pipeline\Pipeline, який можна використати й для власних процесів:
$order = Pipeline::send($order)
->through([
ValidateStock::class,
ApplyDiscounts::class,
CalculateTaxes::class,
ReserveItems::class,
])
->thenReturn();
Кожен крок - клас з методом handle($order, Closure $next). Кроки легко переставити, додати, протестувати окремо.
Переваги:
- відокремлення відправника від обробників;
- набір і порядок обробників - конфігурація, а не код (групи middleware, порядок
->through()); - кожен обробник з однією відповідальністю.
Пастки:
- порядок має значення: middleware автентифікації після middleware, що використовує користувача, - помилка. Laravel має
$middleware->priority()для впорядкування; - ніхто не обробив: у класичному варіанті запит може пройти весь ланцюжок без обробки - потрібен обробник за замовчуванням;
- налагодження: довгий ланцюжок з побічними ефектами важко відстежити - кожен крок має бути простим;
- продуктивність: глобальні middleware виконуються на кожен запит, тож у них не місце важким операціям.
Інші приклади: обробники винятків, валідатори з кількома правилами, системи знижок, ланцюжки фільтрів запитів (пошук з фільтрами - кожен фільтр додає умову й передає далі).
Докладніше в документації: Refactoring.Guru: Chain of Responsibility
Обидва патерни розв'язують одну задачу - змінювати частину алгоритму, не змінюючи загальної структури. Різниця - у механізмі.
Template Method - через успадкування. Базовий клас задає скелет алгоритму, а змінні кроки - абстрактні методи для нащадків:
abstract class DataImport
{
final public function run(string $path): ImportResult
{
$rows = $this->read($path); // змінний крок
$valid = array_filter($rows, $this->validate(...));
$this->persist($valid); // змінний крок
return new ImportResult(count($valid), count($rows) - count($valid));
}
abstract protected function read(string $path): array;
abstract protected function validate(array $row): bool;
abstract protected function persist(array $rows): void;
}
final class ProductImport extends DataImport { /* реалізує три кроки */ }
Strategy - через композицію. Змінна частина - окремий об'єкт, переданий ззовні:
final class DataImport
{
public function __construct(
private RowReader $reader,
private RowValidator $validator,
private RowWriter $writer,
) {}
public function run(string $path): ImportResult { /* той самий скелет */ }
}
Порівняння:
| Template Method | Strategy | |
|---|---|---|
| механізм | успадкування | композиція |
| коли обирається варіант | при створенні класу (компіляція) | при створенні об'єкта (виконання) |
| комбінування варіантів | новий підклас на кожну комбінацію | будь-яка комбінація об'єктів |
| доступ до стану | нащадок бачить protected батька |
лише через параметри й інтерфейс |
| тестування кроків | через підклас | кожна стратегія окремо |
Коли Template Method доречний:
- скелет стабільний, варіантів небагато, кроки тісно пов'язані між собою;
- фреймворк визначає життєвий цикл, а ви заповнюєте «гачки»:
Command::handle(),Seeder::run(),Notification::via()/toMail()у Laravel,setUp()у тестах - усе це шаблонні методи; - кроки потребують спільного стану базового класу.
Коли Strategy:
- варіанти комбінуються незалежно (формат × доставка × кешування);
- варіант обирається під час виконання (за налаштуваннями, типом користувача);
- кроки корисно тестувати й повторно використовувати окремо.
Еволюція: часто код починається з Template Method (простіше), а коли комбінацій стає багато - рефакториться в Strategy («замінити успадкування делегуванням»). final на методі-скелеті в Template Method важливий - нащадки не повинні змінювати порядок кроків.
Докладніше в документації: Refactoring.Guru: Template Method
State (Стан) - поведінка об'єкта змінюється залежно від його стану, а кожен стан оформлений окремим класом. Замість умов у кожному методі об'єкт делегує поведінку поточному стану.
Без патерну - умови розповзаються по коду:
class Order
{
public function cancel(): void
{
if ($this->status === 'shipped' || $this->status === 'delivered') {
throw new CannotCancel();
}
if ($this->status === 'paid') {
$this->refund();
}
$this->status = 'cancelled';
}
public function ship(): void
{
if ($this->status !== 'paid') { throw new CannotShip(); }
// ...
}
// і так у кожному методі
}
Новий стан («очікує оплати частинами») - правка всіх методів, легко пропустити перевірку в одному з них.
З патерном State:
abstract class OrderState
{
public function cancel(Order $order): void { throw new InvalidTransition(static::class, 'cancel'); }
public function ship(Order $order): void { throw new InvalidTransition(static::class, 'ship'); }
}
final class Paid extends OrderState
{
public function cancel(Order $order): void
{
$order->refund();
$order->transitionTo(new Cancelled());
}
public function ship(Order $order): void
{
$order->transitionTo(new Shipped());
}
}
final class Shipped extends OrderState { /* cancel не дозволено - поведінка за замовчуванням */ }
Переваги:
- усі правила стану в одному класі: що дозволено зі стану «Оплачено», видно в класі
Paid; - недозволені переходи - помилка за замовчуванням, а не забута перевірка;
- новий стан - новий клас, наявні не змінюються (OCP);
- легко тестувати кожен стан окремо.
Простіша альтернатива - енум зі списком переходів:
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Shipped = 'shipped';
case Cancelled = 'cancelled';
public function canTransitionTo(self $next): bool
{
return in_array($next, match ($this) {
self::Pending => [self::Paid, self::Cancelled],
self::Paid => [self::Shipped, self::Cancelled],
self::Shipped, self::Cancelled => [],
}, true);
}
}
Як обрати:
- різниться лише набір дозволених переходів - енум з таблицею переходів;
- різниться поведінка в кожному стані (різні побічні ефекти, розрахунки, доступні дії) - класи станів.
У Laravel готове рішення - spatie/laravel-model-states: стани як класи, збережені в колонці моделі, з описаними переходами й власними класами-переходами для логіки.
Технічний борг - метафора Ворда Каннінгема: швидке, неідеальне рішення - як позика. Ви отримуєте швидкість зараз, але платите відсотки: кожна наступна зміна в цьому коді коштує дорожче. Якщо борг не гасити, відсотки з'їдають усю швидкість команди.
Борг буває різним (квадрант Мартіна Фаулера):
- Свідомий і обачний: «випускаємо зараз, бо дедлайн, і знаємо, що треба буде переробити». Нормальне бізнес-рішення.
- Свідомий і необачний: «на дизайн немає часу» - постійно.
- Несвідомий і обачний: «тепер ми розуміємо, як це треба було зробити» - природний наслідок навчання.
- Несвідомий і необачний: команда просто не знає, як краще.
Як керувати:
- Робити видимим. Задачі в трекері з описом наслідків, а не «відрефакторити X»: «додавання нового способу оплати займає 3 дні замість пів дня через Y».
- Говорити мовою бізнесу. «Рефакторинг» не продається; «зменшимо кількість інцидентів у платежах» і «пришвидшимо релізи нових інтеграцій» - продаються.
- Гасити постійно, а не «колись». Частина ємності кожного спринту, правило бойскаута при роботі в коді, а не окремий «спринт рефакторингу» раз на рік.
- Пріоритезувати за відсотками. Найдорожчий борг - у коді, який часто змінюється. Заплутаний модуль, якого ніхто не чіпав три роки, може почекати. Аналіз «hotspots» (частота змін × складність файлів з історії Git) показує, де борг коштує найбільше.
- Не накопичувати нового непомітно: код-рев'ю, статичний аналіз у CI, тести як умова злиття.
Найнебезпечніше - коли борг не усвідомлюють: команда сповільнюється, оцінки ростуть, баги множаться, а причину шукають у людях, а не в коді.
Bounded context (обмежений контекст) - поняття з DDD: межа, всередині якої терміни й модель мають одне чітке значення. У великій системі одне слово означає різне в різних частинах.
«Товар» у каталозі - опис, фото, характеристики. На складі - залишки, комірка, вага. У бухгалтерії - собівартість і податкова група. Спроба зробити одну модель Product для всіх трьох дає клас на сотні полів, який змінюють усі команди й ніхто не розуміє повністю.
Ідея: кожен контекст має свою модель того, що йому потрібно. Контексти пов'язані через ідентифікатори й явні контракти, а не через спільні таблиці й класи.
Як ділити моноліт на модулі (модульний моноліт):
- Знайти межі за мовою й бізнес-процесами: де змінюється значення термінів, які частини змінюються разом, які команди відповідають за що.
- Модуль = каталог з публічним API:
app/Billing,app/Catalog,app/Shipping. Інші модулі звертаються лише до публічного інтерфейсу (сервіси, події), а не до внутрішніх моделей і таблиць. - Зв'язок між модулями - через події (
OrderPlaced→ Billing створює рахунок) або явні виклики фасаду модуля. - Свої таблиці для модуля. Запити
JOINчерез межу модуля - сигнал, що межа неправильна або потрібна копія даних. - Автоматична перевірка меж: архітектурні тести (Pest
arch(), Deptrac) забороняютьCatalogімпортувати внутрішні класиBilling.
Чому модульний моноліт, а не одразу мікросервіси: межі спершу майже завжди визначають неточно. Перенести код між модулями - рефакторинг; між мікросервісами - міграція даних, нові API й розподілені транзакції. Добре відокремлений модуль легко винести в сервіс пізніше, коли для цього з'явиться справжня причина: незалежне масштабування, окрема команда, інший цикл релізів.
Великі зміни (заміна ORM-шару, платіжного провайдера, пошукового рушія, переписування ключового модуля) в окремій гілці Git на тижні - класична пастка: гілка відстає від main, злиття стає болісним, а результат не перевірений у продакшені до самого кінця.
Branch by Abstraction - «гілкування» всередині коду замість гілки в Git:
- ввести абстракцію перед частиною, що змінюється, і перевести на неї всіх клієнтів - поки з єдиною реалізацією, старою:
interface SearchEngine
{
public function search(SearchQuery $query): SearchResults;
}
final class DatabaseSearch implements SearchEngine { /* наявний код */ }
- поступово писати нову реалізацію поруч - у
main, маленькими комітами, з тестами:
final class MeilisearchSearch implements SearchEngine { /* нова */ }
- перемикати клієнтів на нову реалізацію - через конфігурацію чи feature flag, частинами:
$this->app->bind(SearchEngine::class, fn () => Feature::active('new-search')
? app(MeilisearchSearch::class)
: app(DatabaseSearch::class));
- видалити стару реалізацію, а за потреби - і саму абстракцію, якщо вона більше не потрібна.
Переваги:
- код завжди в робочому стані і постійно інтегрується - немає великого злиття;
- зміни потрапляють у продакшен поступово і можуть бути вимкнені миттєво;
- паралельна робота: інша команда продовжує розробку, не чекаючи на завершення рефакторингу;
- перевірка на реальних даних: нову реалізацію можна ввімкнути для частини користувачів чи запускати «в тіні» й порівнювати результати.
Інструменти:
- 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. Варто стежити, щоб кількість записів лише зменшувалася.
У будь-якому великому проєкті поганого коду більше, ніж часу на його виправлення. Рефакторити «все погане» неможливо - і не потрібно: складний модуль, який ніхто не змінює роками, нікому не заважає.
Аналіз гарячих точок (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 - для оцінки складності.
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 заповнив поле. Порядок стає неявним контрактом - його варто документувати чи перевіряти;
- мутація спільного об'єкта: кроки змінюють той самий об'єкт - важко зрозуміти, хто що змінив. Для складних процесів - незмінні об'єкти, які кожен крок повертає новими;
- надмірність: три рядки лінійного коду не потребують пайплайну з трьох класів;
- обробка помилок: виняток у середині зупиняє весь процес - має бути зрозуміло, що відбувається з уже зробленими змінами.
Проблема: всередині транзакції відправляються події чи ставляться задачі в чергу, але транзакція ще не закомічена.
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:
- в тій самій транзакції, що й зміна даних, записати повідомлення в таблицю
outbox; - окремий процес читає
outboxі відправляє повідомлення (в чергу, брокер, вебхук), позначаючи відправлені; - споживачі - ідемпотентні, бо повідомлення може прийти двічі.
Так зміна даних і факт «треба повідомити» атомарні - або обидва є, або жодного.
Інші правила:
- зовнішні виклики (HTTP, платежі) не робити всередині транзакції - транзакція тримає блокування весь час очікування відповіді, а відкотити зовнішню дію неможливо;
- транзакція - якомога коротша і лише навколо змін бази;
- тести:
RefreshDatabaseобгортає тест у транзакцію, тож «після коміту» в тестах поводиться особливо - 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 (окремі класи-запити) - він дає більшість користі майже без ціни.
Питання рівня Senior з реальних технічних співбесід - 30 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії