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

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

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

100 питань

Шарова архітектура ділить застосунок на горизонтальні шари з різною відповідальністю. Кожен шар залежить лише від шару під ним.

Класичні три шари:

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

Часто між представленням і доменом виділяють ще шар застосунку (application/service layer) - сценарії використання: «оформити замовлення» координує доменну логіку, транзакцію, відправку листа.

Навіщо:

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

У Laravel це виглядає приблизно так:

Http/Controllers, Livewire, Console    ← представлення
Actions / Services                     ← сценарії
Models (+ доменні класи, Enums)        ← домен і дані разом (Active Record)

Eloquent за патерном Active Record поєднує доменний об'єкт і доступ до даних - шари «домен» і «дані» тут свідомо злиті заради простоти.

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

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

Мартін Фаулер радить на рівні великої системи ділити спершу за предметними областями (замовлення, каталог, оплата), а шари - всередині кожної: інакше кожна зміна фічі зачіпає всі шари всього застосунку одночасно.

Докладніше в документації: Martin Fowler: Presentation Domain Data Layering

YAGNI (You Aren't Gonna Need It) - «вам це не знадобиться»: не реалізовувати можливість, доки вона справді не потрібна.

KISS (Keep It Simple) - обирати найпростіше рішення, що розв'язує задачу.

Обидва - про боротьбу з передчасною складністю, але з різних боків: YAGNI - про що будувати (не будувати зайвого), KISS - про як (не ускладнювати те, що будуєте).

Типові порушення YAGNI:

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

Чому це дорого (за Фаулером):

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

Важливе уточнення: YAGNI стосується можливостей, а не якості коду. Він не означає «не писати тестів», «не рефакторити» чи «не думати про структуру». Навпаки - чистий, протестований код дешево змінювати, коли нова вимога справді прийде. Саме це робить YAGNI безпечним.

Як це виглядає в Laravel-проєкті:

  • спершу - Eloquent напряму в діях, без репозиторіїв «на майбутнє»;
  • один клас-сервіс замість ієрархії стратегій, доки варіант один;
  • конфігурація в config/, а не адмінка налаштувань, доки її ніхто не змінює.

Коли передбачення виправдане: рішення, які дорого змінити потім - схема публічного API, формат даних, вибір основної бази, безпека. Тут думати наперед доречно; у внутрішньому коді - краще відкласти.

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

Проблема: клас сам створює свої залежності - і в тесті їх неможливо замінити.

class OrderService
{
    public function place(Order $order): void
    {
        $gateway = new StripeGateway(config('services.stripe.key'));   // справжній платіж у тесті?
        $gateway->charge($order->total);
    }
}

Впровадження залежностей - клас отримує залежності ззовні (зазвичай через конструктор):

class OrderService
{
    public function __construct(private PaymentGateway $gateway) {}

    public function place(Order $order): void
    {
        $this->gateway->charge($order->total);
    }
}

Сервіс-контейнер Laravel створить OrderService і підставить реалізацію PaymentGateway, зареєстровану в провайдері. А в тесті можна підставити тестовий дублер.

Види тестових дублерів (за Джерардом Месарошем):

  • dummy - об'єкт-заглушка, що лише заповнює параметр і не використовується;
  • stub - повертає заздалегідь задані відповіді («платіж завжди успішний»);
  • spy - запам'ятовує виклики, щоб потім перевірити, як його використовували;
  • mock - заздалегідь налаштовані очікування викликів, що перевіряються автоматично;
  • fake - робоча спрощена реалізація (база в пам'яті, платіжний шлюз, що зберігає транзакції в масиві).

У Laravel багато фейків уже готові:

Mail::fake();
Queue::fake();
Http::fake(['api.stripe.com/*' => Http::response(['status' => 'succeeded'])]);
Storage::fake('s3');

$this->app->instance(PaymentGateway::class, new FakePaymentGateway());

Чому fake часто кращі за mock: mock перевіряє, як код викликає залежність (порядок, аргументи), і ламається при будь-якому рефакторингу. Fake перевіряє результат («після оформлення в шлюзі є одна транзакція на 500 грн») - тести стійкіші.

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

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

ADR (Architecture Decision Record) - короткий документ про одне архітектурне рішення: що вирішили, чому, які були альтернативи й наслідки.

Навіщо: через рік ніхто не пам'ятає, чому обрали саме Redis для черг, чому немає мікросервісів, чому гроші зберігаються в копійках. Нова людина в команді бачить дивне рішення і або боїться його чіпати, або «виправляє» - і повторює помилку, від якої колись свідомо відмовилися.

Типова структура (шаблон Майкла Найгарда):

# 7. Зберігати суми в копійках цілим числом

## Статус
Прийнято (2026-03-12)

## Контекст
Розрахунки з float давали розбіжності в копійку в звітах.
Декілька валют, у деяких - інша кількість знаків після коми.

## Рішення
Усі суми - BIGINT у мінімальних одиницях + код валюти.
Перетворення у форматований вигляд - лише при виведенні.

## Наслідки
+ точні розрахунки, прості порівняння
- міграція наявних колонок, зміна API (поле amount стає цілим)

Принципи:

  • один ADR - одне рішення;
  • незмінність: прийнятий ADR не редагують по суті. Якщо рішення змінилося - новий ADR, а старий отримує статус «Замінено ADR-15». Так зберігається історія мислення;
  • поруч із кодом - у репозиторії (docs/adr/0007-money-in-cents.md), рецензується в pull request, як код;
  • коротко - сторінка, а не трактат.

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

Що не варто: дрібні рішення, які видно з коду й легко змінити.

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

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

Тестова піраміда (Майк Кон) - модель розподілу тестів за рівнями:

        /\        E2E / UI          - мало: повільні, крихкі, дорогі
       /  \
      /----\      інтеграційні      - середньо
     /      \
    /--------\    модульні (unit)   - багато: швидкі, дешеві, точні

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

«Тестовий трофей» (Кент Доддс), популярний у фронтенді:

     🏆  E2E               - кілька
    ----
   |    | інтеграційні     - НАЙБІЛЬШЕ
   |----|
    unit                   - трохи
  ======
  статичний аналіз         - основа: типи, лінтери

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

Як це виглядає в Laravel-проєкті:

  • статичний аналіз - PHPStan/Larastan, Pint: ловлять цілі класи помилок без жодного тесту;
  • feature-тести (основа більшості Laravel-проєктів) - HTTP-запит до маршруту з реальною базою (RefreshDatabase), фейками пошти й черг. Фактично інтеграційні, але досить швидкі;
  • unit-тести - для чистої логіки зі складними розгалуженнями: розрахунок ціни, парсери, правила;
  • браузерні тести (Pest browser, Dusk) - для критичних сценаріїв з JavaScript.

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

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

Докладніше в документації: Martin Fowler: The Practical Test Pyramid

Три схожі назви - три різні речі:

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

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

Рівні
Junior 35 Middle 35 Senior 30

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