Архітектура: питання на співбесіді рівня Middle
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
35 питань
Три схожі назви - три різні речі:
Dependency Injection (впровадження залежностей) - техніка: об'єкт отримує залежності ззовні (через конструктор), а не створює їх сам.
// Без DI
class ReportService { private $mailer; public function __construct() { $this->mailer = new SmtpMailer(); } }
// З DI
class ReportService { public function __construct(private Mailer $mailer) {} }
Dependency Inversion Principle (DIP, «D» у SOLID) - принцип проєктування:
- Модулі високого рівня (бізнес-логіка) не мають залежати від модулів низького рівня (база, пошта, HTTP). Обидва залежать від абстракцій.
- Абстракції не залежать від деталей - деталі залежать від абстракцій.
«Інверсія» в тому, що інтерфейс належить бізнес-логіці й описаний її мовою, а інфраструктура його реалізує. Не «у нас є Stripe, давайте загорнемо його API в інтерфейс», а «бізнес-логіці потрібно PaymentGateway::charge(), а Stripe - одна з реалізацій».
IoC-контейнер (Service Container у Laravel) - інструмент, який автоматизує DI: сам створює об'єкти й підставляє залежності.
Зв'язок: DI можна робити без DIP - впроваджувати конкретний клас StripeClient. Тоді залежність ззовні, але бізнес-логіка все одно прив'язана до деталі. DIP додає абстракцію, а DI - спосіб її доставити.
Коли DIP виправданий: межі з зовнішнім світом, які справді можуть змінитися чи які треба підміняти в тестах - платежі, пошта, сторонні API, сховища. Інтерфейс на кожен клас «про всяк випадок» лише додає файлів і непрямих викликів без користі.
Усі три загортають інші об'єкти, але з різною метою:
Adapter - змінює інтерфейс: приводить чужий об'єкт до інтерфейсу, який очікує ваш код.
final class TwilioSmsAdapter implements SmsSender // ваш інтерфейс
{
public function __construct(private TwilioClient $twilio) {}
public function send(PhoneNumber $to, string $text): void
{
$this->twilio->messages->create((string) $to, ['body' => $text, 'from' => '...']);
}
}
Decorator - той самий інтерфейс, додана поведінка. Загортає об'єкт і додає щось до або після виклику; декоратори можна нашаровувати.
final class CachedExchangeRates implements ExchangeRates
{
public function __construct(private ExchangeRates $inner, private CacheRepository $cache) {}
public function rate(string $from, string $to): float
{
return $this->cache->remember("rate:$from:$to", 3600, fn () => $this->inner->rate($from, $to));
}
}
Кешування, логування, повтори, метрики - типові декоратори. Обгорнутий і обгортка взаємозамінні.
Facade (класичний, GoF) - спрощений інтерфейс до складної підсистеми: один клас з кількома простими методами замість десятка класів з тонкими налаштуваннями.
Фасади Laravel (Cache::, DB::) - інше: це статичний доступ до сервісів з контейнера (ближче до Service Locator). Назва схожа, але патерн GoF не про це.
Як розрізнити на співбесіді:
- інтерфейс змінюється - Adapter;
- інтерфейс той самий, поведінки більше - Decorator;
- інтерфейс простіший за те, що за ним, - Facade.
Ще схожий Proxy - той самий інтерфейс, але контролює доступ до об'єкта: ліниве створення, перевірка прав, віддалений виклик.
Singleton гарантує, що клас має один екземпляр, і дає до нього глобальний доступ:
final class Config
{
private static ?self $instance = null;
public static function getInstance(): self
{
return self::$instance ??= new self();
}
private function __construct() {}
}
Config::getInstance()->get('app.name');
Чому його вважають антипатерном (точніше - класичну реалізацію):
- прихована залежність: клас, що десь усередині викликає
Config::getInstance(), з конструктора не показує, що залежить від конфігурації; - глобальний стан: будь-який код може змінити спільний об'єкт - важко відстежити, хто й коли;
- тестування: статичний екземпляр не підміниш фейком без хитрощів, а стан «перетікає» між тестами;
- порушення SRP: клас відповідає і за свою роботу, і за керування власним життєвим циклом;
- довгоживучі процеси (Octane, черги): статичний стан живе між запитами - дані одного користувача можуть потрапити до іншого.
Задача «один екземпляр на застосунок» справжня - з'єднання з базою, клієнт API, конфігурація. Розв'язувати її краще контейнером:
// AppServiceProvider
$this->app->singleton(PaymentGateway::class, fn () => new StripeGateway(config('services.stripe.secret')));
// використання - звичайна залежність
final class CheckoutService
{
public function __construct(private PaymentGateway $payments) {}
}
- один екземпляр - контейнер створює об'єкт один раз і повертає його всім;
- явна залежність у конструкторі;
- підміна в тестах:
$this->app->instance(PaymentGateway::class, new FakeGateway)чиswap; - клас нічого не знає про те, що він «єдиний» - це рішення конфігурації, а не самого класу.
scoped() замість singleton() для Octane: екземпляр живе в межах одного запиту чи задачі в черзі і скидається між ними - захист від витоку стану між користувачами.
Фасади Laravel - не Singleton у класичному сенсі: це статичний доступ до об'єкта з контейнера, який можна підмінити (Cache::fake(), Mail::fake()). Але залежність через фасад так само прихована в коді методу, тож для бізнес-класів явне впровадження через конструктор зазвичай читабельніше.
Успадкування повторно використовує код через ієрархію «є різновидом» (Admin extends User). Композиція - через «має» чи «використовує»: об'єкт отримує інші об'єкти й делегує їм роботу.
Проблеми глибокого успадкування:
class Report { public function generate() { /* ... */ } }
class PdfReport extends Report { /* ... */ }
class CachedPdfReport extends PdfReport { /* ... */ }
class CachedEmailedPdfReport extends CachedPdfReport { /* ... */ }
// а потрібен ще CachedEmailedCsvReport...
- комбінаторний вибух: кожна комбінація можливостей - новий клас;
- крихкий базовий клас: зміна в
Reportнепередбачувано ламає нащадків; - жорсткий зв'язок: нащадок залежить від внутрішніх деталей батька (
protected), а не лише від публічного API; - одне успадкування в PHP: клас не може взяти поведінку з двох батьків.
Композицією:
final class ReportGenerator
{
public function __construct(
private ReportFormatter $formatter, // Pdf чи Csv
private ReportDelivery $delivery, // Email чи Storage
private CacheRepository $cache,
) {}
public function run(ReportQuery $query): void
{
$data = $this->cache->remember($query->key(), 3600, fn () => $query->fetch());
$this->delivery->send($this->formatter->format($data));
}
}
Будь-яка комбінація - різні об'єкти в конструкторі, без нових класів. Поведінку можна змінити під час виконання й легко підмінити в тестах.
Інструменти композиції: впровадження залежностей, Strategy, Decorator (обгортання з тим самим інтерфейсом), делегування.
Трейти PHP - механізм повторного використання коду без успадкування, але це радше «копіювання методів» у клас: вони не створюють окремих об'єктів і можуть мати ті самі проблеми прихованих залежностей. Добрі для невеликої поведінки (HasFactory, SoftDeletes), погані як заміна архітектури.
Коли успадкування доречне:
- справжнє відношення «є різновидом», де нащадок повністю замінює батька (принцип Лісков);
- точки розширення фреймворку:
extends Model,extends Controller,extends Command- фреймворк так спроєктований; - абстрактний клас з невеликою спільною логікою і шаблонним методом;
- неглибока ієрархія (1-2 рівні).
Корисне правило: класи за замовчуванням final. Відкривати для успадкування - свідоме рішення, а не випадковість.
Command (Команда) - запит на дію оформлюється окремим об'єктом, що містить усе потрібне для виконання: що зробити і з якими даними. Тоді дію можна передати, відкласти, поставити в чергу, повторити, записати в журнал чи скасувати.
Laravel-джоба - класичний Command:
final class GenerateInvoicePdf implements ShouldQueue
{
use Queueable;
public function __construct(public Invoice $invoice) {}
public function handle(PdfRenderer $renderer): void
{
$this->invoice->update(['pdf_path' => $renderer->render($this->invoice)]);
}
}
GenerateInvoicePdf::dispatch($invoice); // виконати пізніше в черзі
GenerateInvoicePdf::dispatchSync($invoice); // виконати зараз
- об'єкт-команда містить дані (
$invoice) і знає, що робити (handle); - виконавець (воркер черги) не знає деталей - просто викликає
handle; - відправник не знає, коли й де команда виконається.
Що дає такий підхід:
- відкладене й асинхронне виконання - черги;
- повтори при збоях (
$tries,backoff) - команда серіалізується й виконується знову; - ланцюжки й пакети:
Bus::chain([...]),Bus::batch([...])- послідовність команд як дані; - журнал: кожна команда - запис у черзі з параметрами, спробами, помилками (Horizon);
- middleware для команд (
RateLimited,WithoutOverlapping) - спільна поведінка навколо будь-якої команди.
Інші Command у Laravel: Artisan-команди, дії (action-класи з одним методом handle/__invoke), Pipeline з об'єктами-кроками.
Command + скасування (класичний варіант патерну): команда має execute() і undo() - основа функцій «Скасувати» в редакторах. У вебзастосунках частіше роблять компенсуючі дії (повернення коштів, а не «відкат» оплати).
CQRS-підхід з шиною команд: контролер створює команду PlaceOrder, шина знаходить обробник PlaceOrderHandler. Це розділяє «що потрібно зробити» і «як», але додає шар непрямості - доречно у великих доменах, а в звичайному Laravel-застосунку часто досить action-класів.
Важливо для черг: команда має бути ідемпотентною або захищеною від повторного виконання (ShouldBeUnique, перевірка стану) - повтори при збоях і «at least once» доставка можуть виконати її двічі.
Зв'язність (coupling) - наскільки модулі залежать один від одного. Чим вона сильніша, тим більше зміна в одному модулі тягне зміни в інших. До неї прагнуть низької.
Зчеплення (cohesion) - наскільки елементи всередині модуля пов'язані спільною метою. До нього прагнуть високого: усе в класі працює на одну задачу.
Девіз: low coupling, high cohesion.
Ознаки сильної зв'язності:
- клас знає внутрішню будову іншого:
$order->customer->address->city->region->name(порушення «закону Деметри»); - спільний змінюваний глобальний стан;
- зміна формату даних в одному модулі ламає кілька інших;
- неможливо протестувати клас, не піднявши половину застосунку;
- модулі імпортують один одного по колу.
Ознаки слабкого зчеплення:
- клас
Utils/Helperз не пов'язаними функціями; - методи класу використовують різні, непересічні набори полів;
- щоб зробити одну зміну, доводиться правити п'ять різних місць - логіку однієї задачі розмазано.
Як зменшують зв'язність:
- залежати від інтерфейсів, а не конкретних класів;
- спілкуватися через події, коли модулю-джерелу не потрібен результат;
- передавати дані (DTO), а не цілі об'єкти з усіма зв'язками;
- приховувати внутрішню будову:
$order->shippingRegion()замість ланцюжка.
Баланс: нульової зв'язності не буває - модулі мусять взаємодіяти. Мета - щоб залежності були явними, спрямованими в один бік (від деталей до бізнес-логіки) і стабільними. Надмірне розщеплення (абстракції й події скрізь) теж має ціну: логіку однієї дії важко простежити.
Проблема: однаковий switch за типом повторюється в багатьох місцях. Новий тип - зміни в кожному з них, і одне точно забудуть.
function fee(Payment $p): int
{
return match ($p->method) {
'card' => (int) ($p->amount * 0.025),
'bank_transfer' => 500,
'cash' => 0,
};
}
// і ще схожі match для label(), isRefundable(), icon()...
Варіант 1 - enum з методами (PHP 8.1+). Добре, коли поведінка невелика й залежить лише від типу:
enum PaymentMethod: string
{
case Card = 'card';
case BankTransfer = 'bank_transfer';
case Cash = 'cash';
public function fee(int $amount): int
{
return match ($this) {
self::Card => (int) ($amount * 0.025),
self::BankTransfer => 500,
self::Cash => 0,
};
}
}
Знання про тип зібране в одному місці, а match без default змусить обробити новий випадок - інакше UnhandledMatchError, а статичний аналізатор попередить ще раніше.
Варіант 2 - поліморфізм (класи за спільним інтерфейсом). Коли в кожного типу своя складна логіка чи залежності:
interface PaymentProcessor
{
public function fee(int $amount): int;
public function refund(Payment $payment): void;
}
final class CardProcessor implements PaymentProcessor { /* ... */ }
Вибір реалізації - один раз, у фабриці чи контейнері. Далі код викликає методи без розгалужень.
Коли switch нормальний: він трапляється в одному місці (наприклад, у фабриці, що створює потрібний об'єкт), варіантів мало і їхній список стабільний. Поліморфізм заради одного if - зайве ускладнення.
Докладніше в документації: Replace Conditional with Polymorphism
Довгий список параметрів - code smell: виклик важко читати, легко переплутати порядок аргументів однакового типу, а кожна зміна сигнатури зачіпає всі місця виклику.
$this->createInvoice($customerId, $amount, 'UAH', $dueDate, true, false, null, 'uk');
// що означають true, false і null?
Варіанти лікування:
1. Об'єкт параметрів (Introduce Parameter Object) - параметри, що завжди йдуть разом, об'єднуються в клас:
final readonly class InvoiceData
{
public function __construct(
public int $customerId,
public Money $amount,
public DateTimeImmutable $dueDate,
public bool $sendByEmail = true,
public bool $isDraft = false,
public ?string $note = null,
public string $locale = 'uk',
) {}
}
$this->createInvoice(new InvoiceData(
customerId: $customer->id,
amount: Money::uah(125050),
dueDate: now()->addDays(14)->toImmutable(),
));
Іменовані аргументи PHP 8 роблять створення читабельним, значення за замовчуванням прибирають шум, а readonly гарантує незмінність. Часто в такий об'єкт перетікає й логіка (валідація, обчислення), - він стає об'єктом-значенням, а не просто контейнером.
2. Іменовані аргументи без нового класу - для рідкісних викликів з кількома необов'язковими параметрами:
$this->createInvoice($customerId, $amount, dueDate: $date, isDraft: true);
3. Передавати весь об'єкт замість його частин (Preserve Whole Object): calculateShipping($order) замість calculateShipping($order->weight, $order->country, $order->city, $order->express).
4. Прибрати булеві прапорці (Remove Flag Argument): createInvoice(..., isDraft: true) часто означає два різні методи - createDraftInvoice() і issueInvoice().
5. Перенести параметри в конструктор: залежності (сервіси, конфігурація), що передаються в кожен виклик, - у конструктор класу через впровадження залежностей.
Ознака, що проблема глибша: метод приймає багато параметрів, бо робить забагато. Тоді спершу розділити метод (Extract Method/Class), а вже потім дивитися на параметри.
У Laravel-проєктах такі об'єкти часто будують з Form Request ($request->toDto()) чи використовують spatie/laravel-data, що поєднує DTO з валідацією й перетворенням з/у масив.
Докладніше в документації: Каталог рефакторингів: Introduce Parameter Object
Одержимість примітивами (Primitive Obsession) - доменні поняття представлені «голими» рядками, числами й масивами: email - string, гроші - float, телефон - string, діапазон дат - два окремі DateTime.
function transfer(string $fromIban, string $toIban, float $amount, string $currency): void
Проблеми:
- валідація розкидана - кожне місце, що приймає IBAN, мусить перевіряти формат (і одне колись забуде);
- легко переплутати аргументи однакового типу (
$toIban, $fromIban) - компілятор не допоможе; - неявні правила (гроші не можна додавати в різних валютах, сума не від'ємна) повторюються чи пропускаються;
- поведінка розповзається по допоміжних функціях (
formatPhone(),normalizeEmail()).
Ліки - об'єкт-значення (Value Object):
final readonly class Money
{
private function __construct(public int $cents, public Currency $currency) {}
public static function of(int $cents, Currency $currency): self
{
if ($cents < 0) {
throw new InvalidArgumentException('Сума не може бути від\'ємною');
}
return new self($cents, $currency);
}
public function add(self $other): self
{
if ($other->currency !== $this->currency) {
throw new CurrencyMismatch();
}
return new self($this->cents + $other->cents, $this->currency);
}
public function equals(self $other): bool
{
return $this->cents === $other->cents && $this->currency === $other->currency;
}
}
function transfer(Iban $from, Iban $to, Money $amount): void
Властивості об'єкта-значення:
- валідний за побудовою: створити некоректний
Ibanнеможливо - перевірка в одному місці; - незмінний (
readonly): операції повертають новий об'єкт; - рівність за значенням, а не за посиланням;
- поведінка поруч з даними:
$money->add(),$email->domain(),$phone->formatted().
У Laravel: власні касти (CastsAttributes) перетворюють колонки бази на об'єкти-значення й назад:
protected function casts(): array
{
return ['price' => MoneyCast::class, 'email' => EmailCast::class];
}
Енуми PHP - теж ліки від одержимості примітивами для фіксованих наборів значень (статуси, ролі, валюти).
Де зупинитися: не кожен рядок потребує класу. Об'єкт-значення окупається, коли з поняттям пов'язані правила (валідація, операції, форматування) і воно передається між шарами застосунку.
Докладніше в документації: Refactoring.Guru: Primitive Obsession
Повне переписування з нуля («великий вибух») - один з найризикованіших проєктів: старою системою треба користуватися, поки пишеться нова; нова довго не дає жодної цінності; функції старої системи, про які всі забули, виявляються в останній момент; а дата перемикання постійно зсувається.
Strangler Fig (фікус-«душитель», що поступово обвиває й замінює дерево) - поступова заміна: нова система росте навколо старої, забираючи функціональність частинами, доки стара не стане непотрібною.
Як це виглядає:
- фасад/маршрутизатор перед системами - проксі, балансувальник, маршрути застосунку - вирішує, яка система обробляє запит;
- обрати шматок - бажано цінний і відносно відокремлений (наприклад, каталог чи звіти);
- реалізувати його в новій системі й перенаправити відповідні запити;
- повторювати, доки старої системи не лишиться;
- вимкнути стару систему.
┌──────────────► нова система (каталог, кошик)
запити ──► маршрутизатор
└──────────────► стара система (решта)
Варіант у межах одного застосунку (наприклад, міграція з самописного PHP на Laravel): Laravel приймає всі запити, нові маршрути обробляє сам, а для невідомих - передає старому коду (fallback-маршрут, що підключає старий фронт-контролер). Модуль за модулем код переїжджає в Laravel.
Що важливо:
- спільні дані - найскладніше. Варіанти: обидві системи працюють з однією базою (простіше, але зв'язує їх), синхронізація подіями, поступова міграція таблиць з шаром сумісності;
- кожен крок дає цінність і може йти в продакшен - немає «року без релізів»;
- можливість відкату: якщо нова частина має проблеми, маршрутизатор повертає трафік на стару;
- порівняння результатів: для критичних частин - запуск обох реалізацій паралельно й порівняння відповідей (shadow traffic) перед перемиканням;
- доводити до кінця: найчастіша проблема - «тимчасово» дві системи на роки. Потрібен план вимкнення старої.
Пов'язані прийоми: Branch by Abstraction - та сама ідея всередині коду (абстракція перед старою реалізацією, нова реалізація за прапорцем), feature flags для поступового перемикання.
Докладніше в документації: Мартін Фаулер: Strangler Fig Application
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: фасади чи впровадження залежностей
Питання рівня Middle з реальних технічних співбесід - 35 питань у 7 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 78 відкритих вакансій рівня Middle. Переглянути вакансії