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

Middle: питання на співбесіді з теми «Патерни й SOLID»

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

5 питань

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

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