Skip to main content

Що таке провайдери і як працює 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 екземпляри

Швидкий приклад

typescript
// 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():

  1. Nest сканує кожен @Module() і збирає його providers, controllers і imports
  2. Для кожного провайдера Nest читає типи параметрів конструктора через reflect-metadata
  3. Nest будує направлений ациклічний граф провайдерів і резолвить глибші залежності першими
  4. Nest створює один екземпляр кожного провайдера у правильному порядку і кешує його за токеном
  5. Коли приходить 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 на співбесіді.

typescript
// 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()

typescript
// Неправильно - немає декоратора export class UsersService { findAll() { return []; } } // Nest пропускає цей клас під час резолвингу; контролер отримує undefined

Додай @Injectable() і контейнер розпізнає клас.

Провайдер зареєстровано в неправильному модулі

typescript
// Неправильно - 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 UsersController

UsersModule або сам оголошує UsersService у своєму providers, або AuthModule має його export, а UsersModule - import: [AuthModule]. Забутий exports - це причина номер один чому ця помилка з'являється на свіжому проекті.

Циклічна залежність без forwardRef

typescript
// UsersService впроваджує AuthService, AuthService впроваджує UsersService // Без forwardRef: bootstrap падає з помилкою circular dependency // З forwardRef: @Injectable() export class UsersService { constructor( @Inject(forwardRef(() => AuthService)) private auth: AuthService ) {} }

Невідповідність scope: request-scoped всередині синглтона

typescript
// Неправильно: 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, які говорять контейнеру що саме передавати, без покладання на відбиті типи.

Приклади

Базовий: контролер впроваджує сервіс

typescript
// 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.

typescript
// 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 для пулу з'єднань до бази

typescript
// 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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?