Пошук уроків, статей та іншого контенту
Чому конструктори класів у NestJS та подібних фреймворках виглядають так, ніби залежності «самі звідкись беруться» — і що насправді відбувається.
Dependency Injection, або впровадження залежностей, — це спосіб передати об’єкту все, що йому потрібно для роботи, замість того щоб об’єкт створював ці залежності самостійно.
Уявімо сервіс, який працює з користувачами:
class UsersService {
private readonly repository = new UsersRepository();
findById(id: number) {
return this.repository.findById(id);
}
}На перший погляд код простий. Але UsersService тепер жорстко залежить від конкретного UsersRepository:
сервіс сам вирішує, як створити репозиторій;
складніше підмінити репозиторій під час тестування;
складніше змінити реалізацію;
створення об’єктів і бізнес-логіка змішані в одному класі.
За Dependency Injection залежність передають ззовні:
class UsersService {
constructor(private readonly repository: UsersRepository) {}
findById(id: number) {
return this.repository.findById(id);
}
}Тепер UsersService не створює UsersRepository. Він лише повідомляє:
«Для роботи мені потрібен об’єкт
UsersRepository».
Хтось інший має створити цей об’єкт і передати його в конструктор. У NestJS цим займається контейнер залежностей.
Термін injection означає «впорскування» або «впровадження». Залежність ніби впроваджується в об’єкт ззовні.
Без Dependency Injection:
class OrdersService {
private readonly paymentsService = new PaymentsService();
}OrdersService сам створює PaymentsService.
З Dependency Injection:
class OrdersService {
constructor(
private readonly paymentsService: PaymentsService,
) {}
}OrdersService отримує вже готовий PaymentsService через конструктор.
Це не означає, що залежність буквально «береться з повітря». Її створює спеціальний механізм — контейнер залежностей.
Залежність — це будь-який об’єкт або сервіс, який потрібен іншому об’єкту для роботи.
Наприклад:
class UsersController {
constructor(
private readonly usersService: UsersService,
) {}
}Тут UsersService — залежність UsersController.
Якщо UsersService використовує UsersRepository:
class UsersService {
constructor(
private readonly usersRepository: UsersRepository,
) {}
}То UsersRepository — залежність UsersService.
У результаті утворюється граф залежностей:
UsersController
↓
UsersService
↓
UsersRepositoryКонтейнер має пройти цим графом, створити потрібні об’єкти в правильному порядку та передати їх конструкторам.
Розглянемо типовий приклад:
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
findAll() {
return [];
}
}import { Controller, Get } from '@nestjs/common';
@Controller('users')
export class UsersController {
constructor(
private readonly usersService: UsersService,
) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}На рівні JavaScript конструктор виглядає приблизно так:
constructor(usersService) {
this.usersService = usersService;
}У ньому немає коду, який явно створює new UsersService().
Під час запуску NestJS:
читає опис модулів і провайдерів;
знаходить UsersService серед зареєстрованих провайдерів;
бачить, що UsersController має залежність від UsersService;
створює екземпляр UsersService;
передає його в конструктор UsersController;
реєструє готовий контролер у застосунку.
Тому здається, що залежність «сама звідкись взялася». Насправді її створив контейнер NestJS.
@Injectable()Декоратор @Injectable() повідомляє NestJS, що клас можна використовувати як провайдер і що контейнер може керувати його залежностями.
import { Injectable } from '@nestjs/common';
@Injectable()
export class MailService {
send(message: string) {
console.log(message);
}
}Після цього сервіс можна впроваджувати в інший клас:
import { Injectable } from '@nestjs/common';
@Injectable()
export class NotificationsService {
constructor(
private readonly mailService: MailService,
) {}
notify(message: string) {
this.mailService.send(message);
}
}У сучасному NestJS @Injectable() зазвичай ставлять на клас, який сам є провайдером або має власні залежності. Клас, у який передають залежність, може бути контролером, сервісом, guard, interceptor та іншою NestJS-складовою.
Важливо: одного лише @Injectable() недостатньо. Провайдер також має бути доступним контейнеру — наприклад, зареєстрованим у модулі або експортованим з іншого модуля.
Найчастіше провайдери реєструють через масив providers:
import { Module } from '@nestjs/common';
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}Тут NestJS дізнається, що:
UsersController — контролер цього модуля;
UsersService — провайдер, який можна створити та впровадити.
Після цього такий конструктор працюватиме:
export class UsersController {
constructor(
private readonly usersService: UsersService,
) {}
}Якщо UsersService не буде зареєстрований у доступному модулі, NestJS не зможе його створити й покаже помилку під час запуску застосунку.
У цьому коді:
constructor(
private readonly usersService: UsersService,
) {}TypeScript одночасно виконує дві дії:
оголошує параметр конструктора;
створює поле класу usersService.
Це скорочений запис такого коду:
class UsersController {
private readonly usersService: UsersService;
constructor(usersService: UsersService) {
this.usersService = usersService;
}
}Але важливо розрізняти TypeScript-тип і реальний об’єкт.
UsersService після компіляції не є значенням, яке автоматично створює екземпляр. Це лише типова інформація та, у відповідному середовищі, метадані, за якими NestJS визначає потрібний токен залежності.
Сам екземпляр створює контейнер.
Контейнер залежностей — це об’єкт або підсистема, яка:
зберігає інформацію про провайдери;
знає, як їх створювати;
знаходить залежності;
передає їх конструкторам;
може повторно використовувати створені екземпляри.
У спрощеному вигляді логіка контейнера могла б виглядати так:
class Container {
private readonly instances = new Map();
resolve<T>(token: new (...args: any[]) => T): T {
const existingInstance = this.instances.get(token);
if (existingInstance) {
return existingInstance;
}
const instance = new token();
this.instances.set(token, instance);
return instance;
}
}Це лише спрощена ілюстрація. Справжній контейнер має враховувати:
залежності конструктора;
модулі;
різні токени;
області життя об’єктів;
циклічні залежності;
фабрики та значення;
помилки конфігурації.
У NestJS контейнер є частиною внутрішньої системи фреймворку.
У NestJS залежності називаються провайдерами. Провайдером може бути:
клас;
готове значення;
фабрична функція;
інший провайдер, прив’язаний до токена.
Найпоширеніший варіант — клас:
@Module({
providers: [UsersService],
})
export class UsersModule {}Це скорочення для такого запису:
@Module({
providers: [
{
provide: UsersService,
useClass: UsersService,
},
],
})
export class UsersModule {}provide — це токен, за яким контейнер шукає залежність.
useClass — клас, екземпляр якого потрібно створити.
Іноді залежністю є не конкретний клас, а абстракція. Наприклад, застосунку потрібне сховище користувачів, але конкретна реалізація може бути різною.
Для цього використовують токен:
export const USERS_REPOSITORY = 'USERS_REPOSITORY';Реєстрація провайдера:
@Module({
providers: [
{
provide: USERS_REPOSITORY,
useClass: PostgresUsersRepository,
},
],
})
export class UsersModule {}Впровадження через @Inject():
import { Inject, Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
constructor(
@Inject(USERS_REPOSITORY)
private readonly usersRepository: UsersRepository,
) {}
findAll() {
return this.usersRepository.findAll();
}
}Тепер UsersService залежить не від конкретного PostgresUsersRepository, а від токена USERS_REPOSITORY.
Це дає змогу замінити реалізацію:
{
provide: USERS_REPOSITORY,
useClass: InMemoryUsersRepository,
}Наприклад, у тестовому середовищі можна використовувати сховище в пам’яті замість підключення до бази даних.
Клас не відповідає за створення всіх об’єктів, які йому потрібні. Він лише використовує їхній контракт.
class ReportsService {
constructor(
private readonly storage: Storage,
) {}
}ReportsService не мусить знати, чи зберігаються файли на диску, у хмарному сховищі або в пам’яті.
Залежність можна підмінити тестовою реалізацією:
const fakeMailService = {
send: () => undefined,
};
const notificationsService = new NotificationsService(
fakeMailService as MailService,
);Тест не повинен надсилати реальний лист, підключатися до поштового сервера чи створювати складну інфраструктуру.
Контейнер може створити один екземпляр сервісу та передавати його різним класам. Це зручно для сервісів без внутрішнього стану або сервісів, які мають бути спільними в межах певної області життя.
Замість пошуку всіх місць із new ConcreteImplementation() змінюють конфігурацію провайдера.
За замовчуванням NestJS зазвичай створює один екземпляр провайдера та повторно використовує його в межах застосунку. Такий режим називають singleton.
У деяких випадках потрібна інша поведінка:
новий екземпляр на кожен запит;
новий екземпляр для кожного виконання;
обмеження часу життя залежності.
NestJS підтримує різні scope провайдерів, зокрема DEFAULT, REQUEST і TRANSIENT.
Приклад request-scoped провайдера:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
// Цей сервіс має окремий екземпляр для кожного запиту
}Request scope може бути корисним для даних, пов’язаних із конкретним HTTP-запитом. Водночас його не варто використовувати без потреби, оскільки залежності можуть ставати request-scoped каскадно, а це впливає на продуктивність і життєвий цикл об’єктів.
Без DI клас сам контролює створення своїх залежностей:
class OrderService {
private readonly paymentService = new PaymentService();
}З DI контроль переходить до зовнішнього механізму:
class OrderService {
constructor(
private readonly paymentService: PaymentService,
) {}
}Це приклад інверсії контролю, або Inversion of Control.
Клас більше не вирішує:
коли створювати залежність;
яку саме реалізацію використовувати;
чи потрібно перевикористовувати екземпляр;
як налаштувати залежність.
Ці рішення приймає контейнер або конфігурація застосунку.
Dependency Injection — один зі способів реалізувати інверсію контролю.
Якщо сервіс не зареєстрований у providers, NestJS не зможе його впровадити:
@Module({
providers: [],
})
export class UsersModule {}Потрібно додати його:
@Module({
providers: [UsersService],
})
export class UsersModule {}Модулі NestJS мають власний контекст. Провайдер з одного модуля не завжди автоматично доступний в іншому.
Якщо провайдер потрібен зовні, його експортують:
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}Після цього модуль, який використовує сервіс, має імпортувати UsersModule.
Якщо провайдер зареєстрований за рядковим токеном:
{
provide: 'USERS_REPOSITORY',
useClass: PostgresUsersRepository,
}його потрібно отримувати через той самий токен:
constructor(
@Inject('USERS_REPOSITORY')
private readonly repository: UsersRepository,
) {}Тип UsersRepository сам по собі не замінює токен 'USERS_REPOSITORY'.
Якщо сервіс уже керується NestJS, не варто паралельно створювати його вручну:
const service = new UsersService(repository);Це може призвести до:
дублювання екземплярів;
втрати конфігурації контейнера;
некоректних scope;
складніших тестів;
пропущених залежностей.
У застосунку краще отримувати такий сервіс через DI.
Проблема виникає, коли:
ServiceA → ServiceB → ServiceAНаприклад, UsersService залежить від OrdersService, а OrdersService — від UsersService.
Циклічні залежності ускладнюють створення об’єктів і часто вказують на проблеми в дизайні. Зазвичай варто:
винести спільну логіку в окремий сервіс;
розділити відповідальності;
залежати від абстракції;
змінити напрямок взаємодії між компонентами.
Спеціальні механізми на кшталт forwardRef() можуть допомогти в окремих випадках, але не повинні бути першим способом вирішення архітектурної проблеми.
Коли ви бачите такий конструктор:
constructor(
private readonly usersService: UsersService,
private readonly logger: LoggerService,
) {}поставте собі три запитання:
Що потрібно цьому класу для роботи?UsersService і LoggerService.
Хто створює ці об’єкти?
У NestJS — контейнер залежностей.
Де вони зареєстровані?
У providers поточного або імпортованого модуля.
Конструктор у такому разі є декларацією залежностей класу. Він не створює магію, а чітко описує, що потрібно передати об’єкту під час створення.
Dependency Injection — це передавання залежностей об’єкту ззовні, замість створення цих залежностей усередині самого об’єкта.
У NestJS:
класи реєструються як провайдери;
модулі описують доступні провайдери;
контейнер залежностей створює їх;
залежності передаються через конструктор;
@Inject() дає змогу використовувати явні токени;
реалізації можна замінювати без змін у класі-споживачі.
Тому конструктор на кшталт:
constructor(
private readonly usersService: UsersService,
) {}означає не те, що UsersService «сам звідкись береться», а те, що NestJS знає, як його знайти, створити та передати в UsersController.