Що таке провайдери і як працює dependency injection у NestJS?
Провайдер у NestJS - це будь-який клас, який IoC контейнер Nest створює і впроваджує за тебе, замість того щоб ти писав new вручну. Сервіси, репозиторії, фабрики, навіть звичайні конфіг-значення - все це може бути провайдером. Контейнер читає типи параметрів конструктора через reflect-metadata під час старту, знаходить відповідний запис у масиві providers модуля, і передає закешований екземпляр ще до того, як перший запит дійде до контролера.
Теорія
TL;DR
- Провайдери передають створення об'єктів контейнеру; контролери оголошують що їм треба, а не як це побудувати
- Аналогія: IoC контейнер - це кухня, яка приймає замовлення (параметри конструктора) і доставляє готові страви (екземпляри). Контролери ніколи не торкаються інгредієнтів
- Головна різниця:
new UsersService()прив'язує класи один до одного; провайдер розв'язує їх, тому заміна одного сервісу автоматично оновлює всіх споживачів - Три умови:
@Injectable()на класі, клас у масивіprovidersмодуля, іemitDecoratorMetadata: trueуtsconfig.json - Scope за замовчуванням - синглтон на рівні застосунку;
Scope.REQUESTабоScope.TRANSIENTдають per-request або per-injection екземпляри
Швидкий приклад
// users.service.ts
import { Injectable } from '@nestjs/common';
@Injectable() // позначає клас як провайдер під управлінням контейнера
export class UsersService {
findAll() {
return [{ id: 1, name: 'Alice' }];
}
}
// users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {} // контейнер впроваджує сюди
@Get()
findAll() {
return this.users.findAll(); // new UsersService() ніде немає
}
}
// users.module.ts
@Module({
controllers: [UsersController],
providers: [UsersService], // реєструємо в контейнері
})
export class UsersModule {}Контролер ніколи не викликає new. Модуль реєструє UsersService як рецепт, контейнер будує його один раз під час bootstrap і передає закешований екземпляр у будь-який конструктор, який оголошує цей тип.
Чому new не підходить
Уяви UsersController, який викликає new UsersService() напряму. Працює один раз. Потім тобі треба замокати сервіс у тестах, поділити один екземпляр між двома контролерами або підмінити його на MockUsersService на staging, і кожне місце де написано new UsersService() доводиться правити одночасно.
DI (впровадження залежностей) вирішує саме цю проблему. Ти оголошуєш що тобі треба, контейнер вирішує як це доставити. У Nest контейнер керує класами, які називаються провайдерами. Це та відповідь, яку очікує інтерв'юер на junior-співбесіді з NestJS - не «клас з @Injectable», а пояснення чому new тут не підходить і що саме робить контейнер замість тебе.
Як контейнер визначає тип за конструктором
Коли ти пишеш constructor(private users: UsersService), компілятор TypeScript зберігає типи параметрів у скомпільований вихід, звідки їх може прочитати reflect-metadata. Nest зчитує цей масив під час завантаження модуля, шукає кожен тип у реєстрі providers, і будує граф залежностей знизу вгору - спочатку глибші залежності.
Три речі мають бути правдою одночасно, щоб впровадження спрацювало:
- На класі є
@Injectable() - Клас присутній у масиві
providersякогось модуля, який бачить застосунок - У
tsconfig.jsonувімкненоemitDecoratorMetadataіexperimentalDecorators
Зламай будь-що з трьох - отримаєш «Nest can't resolve dependencies of X» під час старту.
Що відбувається під час bootstrap
Ось точна послідовність, яку проходить кожен Nest-застосунок коли виконується NestFactory.create():
- Nest сканує кожен
@Module()і збирає йогоproviders,controllersіimports - Для кожного провайдера Nest читає типи параметрів конструктора через
reflect-metadata - Nest будує направлений ациклічний граф провайдерів і резолвить глибші залежності першими
- Nest створює один екземпляр кожного провайдера у правильному порядку і кешує його за токеном
- Коли приходить HTTP-запит, кешований контролер уже тримає кешований сервіс, і хендлер запускається без жодного додаткового wiring
Крок 3 - це місце де падають циклічні залежності. Якщо UsersService потребує AuthService, а AuthService потребує UsersService, у графі є цикл і контейнер не може вирішити який з них будувати першим. Фікс - forwardRef(() => OtherService) з обох сторін. Це говорить контейнеру резолвити посилання ліниво.
Коли використовувати провайдери
- Спільна бізнес-логіка -
UsersService, який використовують кілька контролерів, реєструй як провайдер - Тестованість - впроваджуй мок через
useClassабоuseValueбез зміни вихідного коду - Зовнішні бібліотеки - обгорни клієнт бази даних у провайдер з
useFactoryдля асинхронної ініціалізації - Конфіг-значення -
useValueабоuseFactoryпровайдери для налаштувань з env-змінних - Одноразовий розрахунок - локальна функція підійде; реєструвати провайдер для чогось, що використовується в одному місці, не потрібно
Custom providers: useValue, useClass, useFactory, useExisting
Дев'ять з десяти провайдерів - це просто класи. Десятий потребує custom provider - невеликого об'єкта, який говорить контейнеру як побудувати значення замість того, щоб контейнер просто зробив new. Розуміння коли яку форму обирати - це класичний senior follow-up на співбесіді.
// useValue: передаємо контейнеру вже готове значення
{ provide: 'APP_CONFIG', useValue: { maxRetries: 3, region: 'eu' } }
// useClass: підміняємо один клас іншим з тим самим інтерфейсом
{ provide: MailerService, useClass: MockMailerService }
// useFactory: запускаємо код під час bootstrap (можна async) щоб отримати екземпляр
{
provide: 'DB_CONNECTION',
useFactory: async (config: ConfigService) => createPool(config.get('DB_URL')),
inject: [ConfigService], // контейнер резолвить це перед запуском фабрики
}
// useExisting: псевдонім для провайдера, який вже зареєстровано
{ provide: 'LOGGER', useExisting: PinoLoggerService }Токен (provide:) - це те за чим контейнер ідентифікує провайдера. Для класових провайдерів токен - це сам клас, тому private users: UsersService працює без жодних додаткових анотацій. Для рядкових або Symbol-токенів отримуєш значення через @Inject('DB_CONNECTION') private db: Pool.
Думай про кожну форму як про варіант рецепта: useValue - рецепт із вже готовим результатом, useClass вказує на інший набір інструкцій, useFactory запускає рецепт самостійно, а useExisting - псевдонім для рецепта, який вже є в контейнері.
Scope: синглтон, request, transient
Провайдери є синглтонами за замовчуванням. Один екземпляр на застосунок, спільний для всіх контролерів, які його впроваджують. Scope.REQUEST створює новий екземпляр для кожного HTTP-запиту. Scope.TRANSIENT створює новий екземпляр щоразу, коли щось впроваджує провайдер.
Саме через синглтон за замовчуванням два контролери отримують один і той самий об'єкт UsersService у пам'яті - користувач, створений через один контролер, одразу видимий іншому без жодних зусиль. Зміни scope - і ця гарантія зникає.
Один scope-баг, який регулярно підловлює людей: впровади Scope.REQUEST провайдер у default-scoped сервіс, і синглтон захоплює екземпляр першого запиту і тримає його назавжди. На одному Nest 10 проекті ми впровадили request-scoped TenantContext у default-scoped BillingService, і кожен наступний запит читав дані першого тенанта - зовнішній синглтон ніколи не відпускав перший TenantContext. Фікс - зробити весь ланцюжок залежностей request-scoped, а не тільки листовий провайдер. Документація Nest про це попереджає, але більшість знаходить ту сторінку вже після того, як обпеклися.
Поширені помилки
Забули @Injectable()
// Неправильно - немає декоратора
export class UsersService {
findAll() { return []; }
}
// Nest пропускає цей клас під час резолвингу; контролер отримує undefinedДодай @Injectable() і контейнер розпізнає клас.
Провайдер зареєстровано в неправильному модулі
// Неправильно - UsersService в AuthModule, але UsersController в UsersModule
@Module({ providers: [UsersService] })
export class AuthModule {}
@Module({ controllers: [UsersController] }) // немає imports: [AuthModule]
export class UsersModule {}
// Error: Nest can't resolve dependencies of UsersControllerUsersModule або сам оголошує UsersService у своєму providers, або AuthModule має його export, а UsersModule - import: [AuthModule]. Забутий exports - це причина номер один чому ця помилка з'являється на свіжому проекті.
Циклічна залежність без forwardRef
// UsersService впроваджує AuthService, AuthService впроваджує UsersService
// Без forwardRef: bootstrap падає з помилкою circular dependency
// З forwardRef:
@Injectable()
export class UsersService {
constructor(
@Inject(forwardRef(() => AuthService)) private auth: AuthService
) {}
}Невідповідність scope: request-scoped всередині синглтона
// Неправильно: TenantContext має Scope.REQUEST, BillingService - синглтон
@Injectable()
export class BillingService {
constructor(private tenant: TenantContext) {} // захоплює TenantContext першого запиту назавжди
}
// Правильно: зробити BillingService теж request-scoped
@Injectable({ scope: Scope.REQUEST })
export class BillingService {
constructor(private tenant: TenantContext) {} // свіжий екземпляр на кожен запит
}Де провайдери зустрічаються на практиці
- NestJS CLI-застосунки - кожен згенерований сервіс (
AuthService,UsersService) є провайдером за замовчуванням - Prisma -
PrismaService extends PrismaClientреєструється як провайдер і впроваджується в репозиторії - TypeORM - репозиторії сутностей через
@InjectRepository(User), що є custom provider під капотом - BullMQ - обробники черги як провайдери, впроваджені в контролери або інші сервіси
- ConfigService - глобальний конфіг-провайдер завантажується один раз під час bootstrap і впроваджується куди завгодно
- Тестування -
Test.createTestingModule({ providers: [...] }).overrideProvider(UsersService).useValue(mockService)підміняє реальний сервіс на мок без зміни вихідного коду
Питання на співбесіді
Q: Як Nest розуміє який клас впровадити для параметра конструктора?
A: TypeScript записує типи параметрів у скомпільований вихід коли увімкнено emitDecoratorMetadata. Nest читає ці метадані через reflect-metadata, зіставляє кожен тип з токеном у реєстрі провайдерів модуля і резолвить екземпляр. Без цього флага кожен параметр виглядає як Object і впровадження не спрацьовує.
Q: Яка різниця між singleton, request і transient scope?
A: Singleton (за замовчуванням) створює один екземпляр на застосунок, спільний для всіх. Scope.REQUEST створює новий екземпляр на кожен HTTP-запит. Scope.TRANSIENT - новий екземпляр на кожну точку впровадження. Щоб вийти зі singleton-за-замовчуванням, використовуй @Injectable({ scope: Scope.REQUEST }).
Q: Коли використовувати useFactory замість useClass?
A: Коли провайдер потребує асинхронної ініціалізації або аргументів, які конструктор не може отримати з контейнера. Connection pool до бази, Redis-клієнт і фабричні функції на основі конфігу - типові випадки для useFactory. useClass підходить тільки для синхронної заміни класу.
Q: Як замокати провайдер у тестах без jest.mock()?
A: Створи TestingModule і виклич .overrideProvider(UsersService).useValue(mockService) до .compile(). Це підміняє провайдер у графі контейнера без впливу на глобальний стан модулів - ізоляція тестів набагато чистіша ніж з module-level мокінгом.
Q: Чи сервіс і провайдер - це одне і те саме?
A: Сервіс є провайдером, але провайдер не завжди є сервісом. Репозиторії, фабрики, value bindings і connection pool - все це провайдери в IoC контейнері, хоча жодне з них не підпадає під ярлик «сервіс». Контейнеру байдуже як ти називаєш клас.
Q: Чи працює DI у NestJS без TypeScript?
A: Класове впровадження спирається на emitDecoratorMetadata, тому чистий JavaScript його не підтримує. Обхідний шлях - custom providers з явними масивами inject, які говорять контейнеру що саме передавати, без покладання на відбиті типи.
Приклади
Базовий: контролер впроваджує сервіс
// users.service.ts
import { Injectable } from '@nestjs/common';
@Injectable()
export class UsersService {
private list = ['Alice', 'Bob'];
findAll() { return this.list; }
findOne(name: string) {
return this.list.find(u => u === name) ?? null;
}
}
// users.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { UsersService } from './users.service';
@Controller('users')
export class UsersController {
constructor(private readonly users: UsersService) {}
@Get()
findAll() { return this.users.findAll(); }
@Get(':name')
findOne(@Param('name') name: string) { return this.users.findOne(name); }
}
// GET /users -> ["Alice", "Bob"]
// GET /users/Alice -> "Alice"
// GET /users/Charlie -> nullКонтролер не знає як побудовано UsersService. Він оголошує тип у конструкторі, а контейнер робить усе інше. Жодної фабрики, жодного new, жодного ручного wiring.
Середній: custom provider для впровадження репозиторія
Цей патерн зустрічається в e-commerce або SaaS бекендах, де потрібно підміняти шар бази даних на мок у тестах без зміни AuthService.
// users.repository.ts
export interface UsersRepository {
findByEmail(email: string): Promise<{ id: number; email: string } | null>;
}
// prisma-users.repository.ts
@Injectable()
export class PrismaUsersRepository implements UsersRepository {
constructor(private prisma: PrismaService) {}
async findByEmail(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
}
// auth.module.ts
@Module({
providers: [
AuthService,
{
provide: 'USER_REPO', // рядковий токен для інтерфейсу
useClass: PrismaUsersRepository, // у тестах замінюємо на MockUsersRepository
},
],
controllers: [AuthController],
})
export class AuthModule {}
// auth.service.ts
@Injectable()
export class AuthService {
constructor(
@Inject('USER_REPO') private usersRepo: UsersRepository,
) {}
async validateUser(email: string) {
const user = await this.usersRepo.findByEmail(email);
return user ? { id: user.id, email: user.email } : null;
}
}
// validateUser('alice@example.com') -> { id: 1, email: 'alice@example.com' }
// validateUser('nobody@example.com') -> nullРядковий токен 'USER_REPO' відв'язує AuthService від конкретного класу. У тестовому модулі замінюй useClass: PrismaUsersRepository на useValue: { findByEmail: jest.fn() } - сервіс ніколи не дізнається різниці.
Senior: асинхронний factory provider для пулу з'єднань до бази
// database.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Pool } from 'pg';
@Module({
providers: [
{
provide: 'DB_POOL',
useFactory: async (config: ConfigService): Promise<Pool> => {
const pool = new Pool({ connectionString: config.get('DATABASE_URL') });
await pool.connect(); // перевіряємо з'єднання при старті, а не на першому запиті
return pool;
},
inject: [ConfigService], // контейнер резолвить ConfigService першим, потім запускає фабрику
},
],
exports: ['DB_POOL'],
})
export class DatabaseModule {}
// orders.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { Pool } from 'pg';
@Injectable()
export class OrdersService {
constructor(@Inject('DB_POOL') private db: Pool) {}
async findByUser(userId: number) {
const { rows } = await this.db.query(
'SELECT * FROM orders WHERE user_id = $1',
[userId]
);
return rows;
}
}Фабрика запускається один раз під час NestFactory.create(). Якщо DATABASE_URL неправильний або сервер недоступний, застосунок падає при старті - а не тихо ламається на першому запиті. Саме для цього і потрібні async factory providers.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.