Пошук уроків, статей та іншого контенту
Чому конструктори класів у NestJS та подібних фреймворках виглядають так, ніби залежності «самі звідкись беруться» — і що насправді відбувається.
Уявімо сервіс, який надсилає email через сторонній сервіс і сам створює для цього клієнта:
class UserService {
private emailClient = new SendgridClient(process.env.SENDGRID_KEY);
async register(email: string) {
// ...
await this.emailClient.send(email, "Ласкаво просимо!");
}
}UserService жорстко прив'язаний до конкретної реалізації SendgridClient: щоб протестувати register() без реальних email-запитів, замінити провайдера листів чи перевикористати клас в іншому середовищі — доведеться редагувати сам клас. Кожна така жорстка залежність (tight coupling) ускладнює і тестування, і подальші зміни.
Dependency Injection (DI) — конкретна реалізація ширшого принципу Inversion of Control: замість того щоб клас сам створював свої залежності (new SendgridClient(...) усередині UserService), залежності передаються ззовні, зазвичай через конструктор:
class UserService {
constructor(private emailClient: EmailClient) {} // залежність передається, а не створюється
async register(email: string) {
await this.emailClient.send(email, "Ласкаво просимо!");
}
}
// Тепер легко підставити тестову реалізацію:
const service = new UserService(new FakeEmailClient());UserService більше не знає, яка саме реалізація EmailClient йому дісталась — лише те, що вона відповідає певному контракту (інтерфейсу). Це і є суть DI: клас залежить від абстракції, а не від конкретної реалізації.
У невеликому прикладі вручну передати одну залежність у конструктор — тривіально. У реальному застосунку сотні класів залежать одне від одного (сервіс залежить від репозиторію, репозиторій — від клієнта бази даних, контролер — від кількох сервісів одразу), і вручну «зшивати» весь цей граф в одному місці стає незручно. Фреймворки на кшталt NestJS вирішують це через IoC-контейнер: спеціальний реєстр, який сам створює екземпляри класів і підставляє їм потрібні залежності, базуючись на метаданих (докладніше — урок «Dependency Injection: IoC-контейнер та області видимості» курсу NestJS).
@Injectable()
class EmailService { send(to: string, text: string) { /* ... */ } }
@Injectable()
class UserService {
constructor(private emailService: EmailService) {} // NestJS сам створить і підставить EmailService
}Розробнику не потрібно писати new EmailService() десь у коді — достатньо оголосити обидва класи провайдерами модуля, і контейнер сам визначить порядок створення та підставить готові екземпляри в конструктори, що їх потребують.
Без DI unit-тест UserService.register() реально надсилав би email через справжній SendgridClient (або вимагав би складних хаків із підміною модулів). З DI достатньо передати в конструктор тестову реалізацію EmailClient, яка просто запам'ятовує виклики, — це і швидше, і не вимагає мережевого доступу чи реальних ключів API під час тестів.
Сприймати DI як магію, яку не потрібно розуміти, бо «фреймворк сам розбереться» — коли граф залежностей ламається (циклічна залежність, забутий провайдер у модулі), розібратись без розуміння механізму значно важче.
Створювати залежності напряму через new всередині класу, який отримує DI-фреймворк, — це зводить нанівець сенс DI: клас знову жорстко прив'язується до конкретної реалізації, навіть якщо технічно проєкт «використовує NestJS».
Плутати DI (спосіб передати залежність) із самим по собі інтерфейсом/абстракцією (спосіб описати контракт) — вони працюють разом, але DI не змушує писати абстракції: можна впроваджувати й конкретні класи, просто це знову дає tight coupling до конкретної реалізації.
Dependency Injection — це передача залежностей класу ззовні (найчастіше через конструктор) замість того, щоб клас сам їх створював. Це знижує зв'язаність коду й спрощує тестування, підміняючи реальні залежності тестовими без зміни самого класу. IoC-контейнер (наприклад, у NestJS) автоматизує цей процес для великих графів залежностей, самостійно створюючи й підставляючи потрібні екземпляри на основі метаданих.