Олексій Синяєв
Найняти
Навігація сторінкою статті
Статті 7 хв читання

Dependency Injection і Dependency Inversion у Laravel

Коротко Впровадження залежностей — це механіка: клас отримує свої залежності, а не створює їх. Інверсія залежностей — це проєктне рішення: залежати від абстракції, а не від конкретного класу. Це не одне й те саме.…

Зміст

Коротко

  • Впровадження залежностей — це механіка: клас отримує свої залежності, а не створює їх. Інверсія залежностей — це проєктне рішення: залежати від абстракції, а не від конкретного класу. Це не одне й те саме.
  • Контейнер Laravel робить DI за вас автоматично через type-hint у конструкторі. Біндинг ви пишете лише коли інвертуєте залежність на інтерфейс.
  • Більшості класів інтерфейс не потрібен. Додавайте його на межі, яка змінюється або якій потрібен тестовий шов — платежі, пошта, сховище, зовнішні API, — а не рефлекторно кожному сервісу.
  • Антипатерн, що зводить усе нанівець: діставати залежності через app() у глибині методів замість ін’єкції. Це service locator, і він ховає рівно те, що ви намагалися зробити явним.

Якось мені дістався Laravel-проєкт, де в кожного сервісу був інтерфейс. UserServiceInterface, InvoiceServiceInterface, ReportServiceInterface — у кожного рівно одна реалізація, кожен забіндений один-до-одного в сервіс-провайдері на чотириста рядків. Команда вважала, що «дотримується SOLID». Насправді вони побудували код, де перехід до визначення методу завжди приводив на інтерфейс, і доводилося шукати біндинг, щоб дізнатися, що реально виконується. Абстракція додавала непрямість і не прибирала нічого.

Той проєкт навчив мене відмінності, про яку ця стаття. Впровадження та інверсію залежностей вживають як синоніми, і саме це розмиття призводить до інтерфейсу на кожен клас. Вони розв’язують різні задачі, і розуміння того, що вам реально потрібно, і тримає Laravel-код чистим у міру зростання.

DI і DIP — це не одне й те саме

Впровадження залежностей означає, що клас отримує те, від чого залежить, а не конструює це сам. У Laravel це майже завжди ін’єкція через конструктор, і залежність для вас будує контейнер:

Code
class RegisterUser
{
    public function __construct(
        private Mailer $mailer,
    ) {}
}

Це вже краще, ніж new Mailer(...) всередині методу — клас більше не володіє зв’язуванням. Але зверніть увагу: він досі залежить від конкретного Mailer. Ви впровадили залежність, не інвертувавши її.

Інверсія залежностей — другий, окремий крок: зробити так, щоб високорівневий клас залежав від абстракції, якою володіє він сам, і тоді конкретна реалізація стає деталлю, яку можна підмінити.

Code
interface Mailer
{
    public function send(Message $message): void;
}

class RegisterUser
{
    public function __construct(
        private Mailer $mailer, // now an interface
    ) {}
}

Тепер клас формулює вимогу («щось, що вміє надіслати повідомлення»), а не називає вендора. SMTP, Mailgun, SES чи фейк у тестах — усе це його задовольняє. Це і є інверсія: напрям залежності перевернувся з «високорівневий код залежить від низькорівневої деталі» на «обидва залежать від контракту».

Як контейнер Laravel це насправді резолвить

Для звичайного DI ви не пишете взагалі ніякої конфігурації. Коли контейнер будує RegisterUser, він читає type-hint конструктора і рекурсивно резолвить кожну залежність. Конкретний клас зі своїми розв’язними залежностями просто працює — тому більшості контролерів і джобів Laravel біндинг не потрібен.

Ін’єкція в метод працює так само. Поставте type-hint залежності в екшені контролера, і контейнер передасть її на кожен запит:

Code
public function store(Request $request, Mailer $mailer)
{
    // $mailer resolved by the container for this call
}

У той момент, коли ви залежите від інтерфейсу, контейнер уже не може вгадати. У інтерфейсу немає конструктора, який можна побудувати. Ви маєте сказати йому, яку реалізацію використати, і це єдина причина існування біндингів:

Code
// In a service provider's register() method
$this->app->bind(Mailer::class, MailgunMailer::class);

Тепер усюди, де Mailer вказано type-hint’ом, контейнер віддає MailgunMailer. Перевести весь застосунок на SES — це один рядок тут, а не пошук-заміна по всьому коду. Ця єдина точка зміни — і є вся вигода інверсії.

singleton() ділить стан на весь запит

bind() будує новий екземпляр щоразу; singleton() будує один раз і перевикористовує. Сінглтон, що зберігає змінюваний стан — закешованого користувача, накопичувальний масив, — протікає цим станом у все, що резолвиться в тому ж запиті. Використовуйте singleton() для безстанових залежностей і об’єктів на кшталт з’єднань, а не для всього, що мутує на кожен виклик.

Контекстний біндинг: коли двом споживачам потрібні різні реалізації

Випадок, який виправдовує інтерфейс найясніше, — коли той самий контракт має резолвитися по-різному залежно від того, хто питає. Laravel розв’язує це контекстним біндингом, і саме ця фіча робить інверсію окупною:

Code
$this->app->when(PublicUploadController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('s3'));

$this->app->when(PrivateReportController::class)
    ->needs(Filesystem::class)
    ->give(fn () => Storage::disk('local'));

Обидва контролери просять одну й ту саму абстракцію Filesystem; контейнер видає кожному потрібний диск. Без інтерфейсу варіювати нічого. Це і є різниця між інтерфейсом, який виправдовує своє місце, і тим, що просто додає зайвий перехід.

Коли інтерфейс виправдовує місце — а коли це шум

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

СитуаціяІнтерфейс?Чому
Зовнішня межа (платежі, пошта, сховище, SMS)ТакВендор зміниться, і вам потрібен фейк у тестах
Кілька реальних реалізацій уже сьогодніТакЦе буквально те, для чого абстракція
Заміна, для якої можна назвати конкретну причинуТак«Наступного кварталу переїдемо з SES на Postmark»
Маленький самодостатній сервіс з однією реалізацієюНіВпроваджуйте конкретний клас; додайте інтерфейс у день, коли потрібна друга
Об’єкт-значення або чистий доменний розрахунокНіІнвертувати нічого; у нього немає інфраструктурної залежності

Чесний дефолт у Laravel: впроваджуйте конкретні класи й тягніться за інтерфейсом, коли клас сидить на межі між вашим доменом і зовнішнім світом. «Раптом колись знадобиться» — не причина; «за цим живе платіжний провайдер» — причина.

Тестування через межу

Ось де інверсія окупається напряму. Оскільки споживачі залежать від контракту, тест може підставити фейк, не чіпаючи тестований код. Контейнер робить підміну одним викликом:

Code
public function test_registration_sends_a_welcome_message(): void
{
    $mailer = new FakeMailer();
    $this->app->instance(Mailer::class, $mailer);

    $this->post('/register', [...]);

    $this->assertCount(1, $mailer->sent);
}

Жодного SMTP, жодної мережі, жодної гімнастики з мок-фреймворком — справжній RegisterUser працює з фейковою залежністю, бо завжди залежав лише від інтерфейсу. Це і є тестованість, яку приписують DI, але яка насправді приходить від інверсії: конкретну залежність так чисто не підмінити.

Помилки, які я бачу в реальному коді на Laravel

Інтерфейс на кожен клас

FooServiceInterface з однією реалізацією додає непрямість і не прибирає нічого. Додавайте інтерфейс, коли друга реалізація або тестовий шов реальні.

Service locator замість ін’єкції

Виклик app(Mailer::class) у глибині методу ховає залежність від конструктора. Краще поставте type-hint, щоб клас заявляв, що йому потрібно.

Протікання фреймворк-типів через контракт

Інтерфейс, чиї методи приймають модель Eloquent або Request, нічого не абстрагує. Передавайте через межу прості дані або об’єкти-значення.

Божественні інтерфейси «Manager»

ServiceManagerInterface з п’ятнадцятьма незв’язаними методами — це не контракт. Розбийте його за реальною можливістю, потрібною кожному споживачу.

Станові сінглтони

Біндинг змінюваного об’єкта як singleton() протікає станом через весь запит. Бережіть сінглтони для безстанових сервісів або об’єктів-з’єднань.

Біндинг не в тому місці

Резолв сервісів усередині register() виконується до завантаження всіх провайдерів. Біндинги — у register(), резолв — у boot().

FAQ

У чому різниця між впровадженням та інверсією залежностей?
Впровадження залежностей — це техніка: клас отримує залежності ззовні, а не створює їх. Інверсія залежностей — це принцип проєктування: високорівневий код залежить від абстракції, а не від конкретного класу. Можна впровадити конкретний клас (DI без інверсії); інверсія — це додатковий крок залежності від інтерфейсу.
Чи потрібен інтерфейс кожному сервісу в Laravel?
Ні. За замовчуванням впроваджуйте конкретні класи. Вводьте інтерфейс, коли залежність сидить на зовнішній межі (платежі, пошта, сховище), коли у вас реально кілька реалізацій або коли потрібен чистий тестовий шов. Інтерфейс з єдиною реалізацією зазвичай лише додає непрямість.
Чому Laravel резолвить деякі класи без жодного біндингу?
Контейнер читає type-hint конструктора і будує конкретні класи автоматично, рекурсивно резолвлячи їхні залежності. Біндинг потрібен лише коли ви ставите type-hint на інтерфейс, бо в інтерфейсу немає конструктора, який контейнер міг би побудувати.
Що таке антипатерн service locator?
Виклик app() або resolve() для отримання залежностей усередині методу замість ін’єкції через конструктор. Він ховає, від чого залежить клас, і ускладнює тестування — це протилежність тому, заради чого потрібне впровадження залежностей.
Коли використовувати bind(), а коли singleton()?
Використовуйте bind(), коли кожен споживач має отримати свіжий екземпляр — це безпечний дефолт. Використовуйте singleton() лише для безстанових залежностей або об’єктів-з’єднань, які хочете перевикористати на весь запит. Сінглтон зі змінюваним станом протікає ним у все, що резолвиться після нього.

Схожі статті

Оновлено:

Поділитися статтею

LinkedIn X Email

Зв'язатися

Працюєте над схожою задачею? Давайте обговоримо.

Відкритий до розмови про архітектуру, Laravel, WordPress, продуктивність і практичні інженерні задачі.

Зв'язатися Переглянути кейси

Дивіться також

Статті

Як зрозуміти, що AI-агент втратив контекст: state-canary в AGENTS.md і CLAUDE.md

State-canary — простий observability-патерн для AI coding agents: один рядок стану в кожній відповіді…
Статті

Частина 3. Місяць з AI-щоденником: як шукати зв’язки між сном, стресом і тренуваннями

Як аналізувати AI-щоденник після першого місяця: виправлення розпізнавання, чесна рефлексія з джерелами, Obsidian, вартість…
Статті

Частина 2. Hermes Agent + DeepSeek на Ubuntu: повний мануал AI-щоденника в Telegram

Покроковий мануал: Hermes Agent і DeepSeek на Ubuntu, закритий Telegram-бот, локальний faster-whisper, Markdown vault,…