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

Архітектура: питання на співбесіді рівня 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) - принцип проєктування:

  1. Модулі високого рівня (бізнес-логіка) не мають залежати від модулів низького рівня (база, пошта, HTTP). Обидва залежать від абстракцій.
  2. Абстракції не залежать від деталей - деталі залежать від абстракцій.

«Інверсія» в тому, що інтерфейс належить бізнес-логіці й описаний її мовою, а інфраструктура його реалізує. Не «у нас є Stripe, давайте загорнемо його API в інтерфейс», а «бізнес-логіці потрібно PaymentGateway::charge(), а Stripe - одна з реалізацій».

IoC-контейнер (Service Container у Laravel) - інструмент, який автоматизує DI: сам створює об'єкти й підставляє залежності.

Зв'язок: DI можна робити без DIP - впроваджувати конкретний клас StripeClient. Тоді залежність ззовні, але бізнес-логіка все одно прив'язана до деталі. DIP додає абстракцію, а DI - спосіб її доставити.

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

Докладніше в документації: Dependency inversion principle

Усі три загортають інші об'єкти, але з різною метою:

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 - той самий інтерфейс, але контролює доступ до об'єкта: ліниве створення, перевірка прав, віддалений виклик.

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

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()). Але залежність через фасад так само прихована в коді методу, тож для бізнес-класів явне впровадження через конструктор зазвичай читабельніше.

Докладніше в документації: Refactoring.Guru: Singleton

Успадкування повторно використовує код через ієрархію «є різновидом» (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» доставка можуть виконати її двічі.

Докладніше в документації: Refactoring.Guru: Command

Зв'язність (coupling) - наскільки модулі залежать один від одного. Чим вона сильніша, тим більше зміна в одному модулі тягне зміни в інших. До неї прагнуть низької.

Зчеплення (cohesion) - наскільки елементи всередині модуля пов'язані спільною метою. До нього прагнуть високого: усе в класі працює на одну задачу.

Девіз: low coupling, high cohesion.

Ознаки сильної зв'язності:

  • клас знає внутрішню будову іншого: $order->customer->address->city->region->name (порушення «закону Деметри»);
  • спільний змінюваний глобальний стан;
  • зміна формату даних в одному модулі ламає кілька інших;
  • неможливо протестувати клас, не піднявши половину застосунку;
  • модулі імпортують один одного по колу.

Ознаки слабкого зчеплення:

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

Як зменшують зв'язність:

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

Баланс: нульової зв'язності не буває - модулі мусять взаємодіяти. Мета - щоб залежності були явними, спрямованими в один бік (від деталей до бізнес-логіки) і стабільними. Надмірне розщеплення (абстракції й події скрізь) теж має ціну: логіку однієї дії важко простежити.

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

Проблема: однаковий 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 (фікус-«душитель», що поступово обвиває й замінює дерево) - поступова заміна: нова система росте навколо старої, забираючи функціональність частинами, доки стара не стане непотрібною.

Як це виглядає:

  1. фасад/маршрутизатор перед системами - проксі, балансувальник, маршрути застосунку - вирішує, яка система обробляє запит;
  2. обрати шматок - бажано цінний і відносно відокремлений (наприклад, каталог чи звіти);
  3. реалізувати його в новій системі й перенаправити відповідні запити;
  4. повторювати, доки старої системи не лишиться;
  5. вимкнути стару систему.
            ┌──────────────► нова система (каталог, кошик)
запити ──► маршрутизатор
            └──────────────► стара система (решта)

Варіант у межах одного застосунку (наприклад, міграція з самописного 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 дає змогу одному класу бути і контролером, і джобою, і командою - зручно, але додає «магії».

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Що дає DTO:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Інші рівні
Junior 35 Senior 30

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