Тема 8. Провайдери, сервіси та модульна система NestJS

Практика ін

Впровадження провайдерів через конструктор, множинні залежності

Практика ін'єкції залежностей

Короткий зміст

  • Впровадження залежностей через параметри конструктора
  • TypeScript модифікатори: private readonly для автоматичного присвоєння
  • Множинні залежності: кілька провайдерів в одному конструкторі
  • Автоматичне розв'язання залежностей DI-контейнером
  • Порядок створення екземплярів: від залежностей до споживачів
  • Циклічні залежності: проблема та попередження
  • Приклад: сервіс залежить від репозиторію та логера
  • Приклад: контролер залежить від кількох сервісів
  • Тестування: легке мокування залежностей
  • Best practices: явне оголошення залежностей у конструкторі

Впровадження залежностей через параметри конструктора

У попередніх лекціях ми розглянули концепцію провайдерів та роль сервісів у інкапсуляції бізнес-логіки. Тепер настав час детально зануритися у механіку ін'єкції залежностей (Dependency Injection, DI) — процес, за допомогою якого NestJS автоматично надає провайдери класам, що їх потребують.

Ін'єкція залежностей у NestJS здійснюється через constructor-based injection (ін'єкція через конструктор) — підхід, при якому клас оголошує свої залежності як параметри конструктора, а DI-контейнер автоматично передає відповідні екземпляри провайдерів при створенні об'єкта. Це фундаментальний механізм, що лежить в основі всієї архітектури фреймворку.

Розглянемо базовий приклад впровадження одного провайдера:

import { Injectable } from '@nestjs/common';
import { UsersRepository } from './users.repository';

@Injectable()
export class UsersService {
  // Оголошення залежності через параметр конструктора
  constructor(private readonly usersRepository: UsersRepository) {}

  async findAll() {
    return this.usersRepository.findAll();
  }
}

У цьому прикладі відбувається декілька важливих процесів:

  1. Оголошення залежності: параметр usersRepository: UsersRepository інформує TypeScript та NestJS про те, що UsersService потребує екземпляр UsersRepository
  2. Автоматичне створення властивості: модифікатор private автоматично створює приватну властивість this.usersRepository у класі
  3. Незмінність після ініціалізації: модифікатор readonly гарантує, що властивість не може бути перевизначена після створення об'єкта
  4. Розв'язання залежності: DI-контейнер розпізнає тип параметра (UsersRepository), знаходить відповідний зареєстрований провайдер у модулі та передає його екземпляр у конструктор

🎯 Мета лекції

  • Опанувати механіку впровадження залежностей через конструктор
  • Зрозуміти роль TypeScript модифікаторів у DI
  • Навчитися працювати з множинними залежностями у одному класі
  • Розібрати процес автоматичного розв'язання залежностей DI-контейнером
  • Усвідомити проблему циклічних залежностей та способи їх уникнення
  • Практикувати найкращі практики організації залежностей

🔑 Ключові терміни

  • Constructor-based Injection — впровадження залежностей через параметри конструктора класу
  • Dependency Graph — граф залежностей між класами, що визначає порядок їх ініціалізації
  • Circular Dependency — циклічна залежність, коли два класи залежать один від одного
  • Mock Object — підроблений об'єкт, що імітує поведінку реальної залежності під час тестування

TypeScript модифікатори: private readonly для автоматичного присвоєння

TypeScript надає потужну можливість автоматичного створення властивостей класу з параметрів конструктора через використання модифікаторів доступу (private, public, protected) у поєднанні з модифікатором readonly. Ця можливість значно скорочує кількість шаблонного коду (boilerplate) та робить оголошення залежностей лаконічнішим.

Порівняння традиційного та скороченого синтаксису

Розглянемо два еквівалентні способи оголошення залежностей:

import { Injectable } from '@nestjs/common';
import { UsersRepository } from './users.repository';

@Injectable()
export class UsersService {
  // 1. Оголошення приватної властивості
  private readonly usersRepository: UsersRepository;

  // 2. Присвоєння у конструкторі
  constructor(usersRepository: UsersRepository) {
    this.usersRepository = usersRepository;
  }

  async findAll() {
    return this.usersRepository.findAll();
  }
}

Обидва приклади генерують ідентичний JavaScript-код після компіляції, проте скорочений синтаксис є рекомендованим у NestJS-спільноті завдяки лаконічності та виразності.

Розуміння модифікаторів

private — модифікатор доступу, що робить властивість доступною лише всередині класу. Зовнішні класи не можуть читати чи змінювати приватні властивості. Це забезпечує інкапсуляцію — один із фундаментальних принципів об'єктно-орієнтованого програмування.

@Injectable()
export class UsersService {
  constructor(private usersRepository: UsersRepository) {}

  // ✅ Доступ всередині класу — працює
  async findAll() {
    return this.usersRepository.findAll();
  }
}

// ❌ Доступ ззовні класу — помилка компіляції
const service = new UsersService(repository);
console.log(service.usersRepository); // Error: Property 'usersRepository' is private

readonly — модифікатор незмінності, що дозволяє присвоїти значення властивості лише один раз — у момент ініціалізації (у конструкторі). Будь-які спроби змінити властивість після створення об'єкта призведуть до помилки компіляції.

@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}

  someMethod() {
    // ❌ Спроба перевизначити властивість — помилка компіляції
    this.usersRepository = new UsersRepository(); 
    // Error: Cannot assign to 'usersRepository' because it is a read-only property
  }
}
Завжди використовуйте комбінацію private readonly для ін'єктованих залежностей. Це гарантує, що залежності встановлюються лише один раз у конструкторі та не можуть бути випадково змінені під час життя об'єкта, що запобігає цілому класу потенційних помилок.

Альтернативні модифікатори доступу

Хоча private є найпоширенішим вибором, TypeScript також надає інші модифікатори:

public — властивість доступна ззовні класу. Рідко використовується для залежностей, оскільки порушує інкапсуляцію:

@Injectable()
export class UsersService {
  // ⚠️ Публічна залежність — зовнішні класи можуть її читати
  constructor(public readonly usersRepository: UsersRepository) {}
}

// Тепер ззовні можна отримати доступ
const service = new UsersService(repository);
console.log(service.usersRepository); // ✅ Працює, але це антипатерн

protected — властивість доступна у класі та його нащадках (підкласах). Використовується у сценаріях наслідування:

@Injectable()
export class BaseService {
  constructor(protected readonly logger: LoggerService) {}
}

@Injectable()
export class UsersService extends BaseService {
  someMethod() {
    // ✅ Доступ до logger з батьківського класу
    this.logger.log('Операція виконана');
  }
}
У переважній більшості випадків використовуйте private readonly для залежностей. Модифікатор public робить внутрішню реалізацію класу доступною ззовні, що порушує принцип інкапсуляції. Модифікатор protected використовується лише у сценаріях наслідування, що рідко зустрічається у NestJS-застосунках.

Множинні залежності: кілька провайдерів в одному конструкторі

У реальних застосунках класи рідко мають лише одну залежність. Частіше сервіси координують роботу кількох інших провайдерів: репозиторіїв для доступу до даних, утиліт для обчислень, логерів для запису подій, клієнтів для комунікації з зовнішніми API тощо. NestJS дозволяє оголошувати множинні залежності у конструкторі, і DI-контейнер автоматично розв'яже їх усі.

Синтаксис множинних залежностей

Кожна залежність оголошується як окремий параметр конструктора:

import { Injectable } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { EmailService } from '../email/email.service';
import { LoggerService } from '../logger/logger.service';
import { PasswordService } from '../auth/password.service';

@Injectable()
export class UsersService {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly emailService: EmailService,
    private readonly logger: LoggerService,
    private readonly passwordService: PasswordService
  ) {}

  async register(email: string, name: string, password: string) {
    this.logger.log(`Реєстрація користувача: ${email}`);

    // Перевірка унікальності
    const existingUser = await this.usersRepository.findByEmail(email);
    if (existingUser) {
      throw new ConflictException('Email вже зайнятий');
    }

    // Хешування пароля
    const passwordHash = await this.passwordService.hash(password);

    // Створення користувача
    const user = await this.usersRepository.create({
      email,
      name,
      passwordHash
    });

    // Відправка привітального email
    await this.emailService.sendWelcome(email, name);

    this.logger.log(`Користувача ${email} успішно зареєстровано`);
    return user;
  }
}

У цьому прикладі UsersService має чотири залежності, кожна з яких виконує свою роль:

  1. UsersRepository — доступ до даних користувачів у базі даних
  2. EmailService — відправка електронних повідомлень
  3. LoggerService — запис логів для моніторингу та налагодження
  4. PasswordService — криптографічні операції з паролями

DI-контейнер автоматично створює екземпляри всіх чотирьох провайдерів та передає їх у конструктор UsersService у правильному порядку.

Порядок параметрів у конструкторі

Важливо розуміти, що порядок параметрів у конструкторі не має значення для DI-контейнера — він розв'язує залежності на основі типів параметрів, а не їх позиції. Наступні два варіанти є функціонально ідентичними:

constructor(
  private readonly usersRepository: UsersRepository,
  private readonly emailService: EmailService,
  private readonly logger: LoggerService
) {}

Проте рекомендується дотримуватися логічного порядку для покращення читабельності коду:

  1. Основні залежності (репозиторії, сервіси домену) — на початку
  2. Допоміжні сервіси (логери, утиліти) — наприкінці
  3. Опціональні залежності — у самому кінці
Якщо конструктор містить більше 5-7 залежностей, це може бути сигналом до того, що клас порушує принцип Single Responsibility та виконує занадто багато функцій. Розгляньте можливість розділення логіки на кілька менших, більш сфокусованих сервісів.

Приклад контролера з множинними залежностями

Контролери також часто залежать від кількох сервісів:

import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthService } from '../auth/auth.service';
import { ValidationService } from '../validation/validation.service';

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly authService: AuthService,
    private readonly validationService: ValidationService
  ) {}

  @Post('register')
  async register(@Body() dto: RegisterDto) {
    // Валідація через ValidationService
    await this.validationService.validateRegistration(dto);

    // Реєстрація через UsersService
    const user = await this.usersService.register(
      dto.email,
      dto.name,
      dto.password
    );

    // Генерація токена через AuthService
    const token = await this.authService.generateToken(user.id);

    return {
      user,
      token
    };
  }

  @Get(':id')
  async getProfile(@Param('id') id: string) {
    return this.usersService.findById(+id);
  }
}

Цей контролер координує роботу трьох сервісів для реалізації ендпоінту реєстрації, демонструючи, як множинні залежності дозволяють розділяти відповідальності між спеціалізованими компонентами.


Автоматичне розв'язання залежностей DI-контейнером

Одна з найпотужніших можливостей NestJS — це автоматичне розв'язання залежностей (automatic dependency resolution). Розробнику не потрібно вручну створювати екземпляри провайдерів чи відстежувати порядок їх ініціалізації — DI-контейнер аналізує граф залежностей та виконує всю роботу автоматично.

Як працює розв'язання залежностей

Процес розв'язання залежностей відбувається у кілька етапів:

Крок 1: Аналіз метаданих

Коли NestJS завантажує модуль, він зчитує метадані з декораторів @Module() та @Injectable(). Для кожного зареєстрованого провайдера фреймворк аналізує сигнатуру конструктора через TypeScript-метадані, що генеруються компілятором.

@Module({
  providers: [UsersService, UsersRepository, EmailService, LoggerService]
})
export class UsersModule {}

NestJS сканує цей модуль та виявляє чотири провайдери, які потрібно зареєструвати у DI-контейнері.

Крок 2: Побудова графу залежностей

DI-контейнер будує граф залежностей (dependency graph) — структуру даних, що відображає, які провайдери від яких залежать. Для кожного провайдера аналізуються параметри конструктора:

// LoggerService не має залежностей
@Injectable()
export class LoggerService {}

// EmailService не має залежностей
@Injectable()
export class EmailService {}

// UsersRepository не має залежностей
@Injectable()
export class UsersRepository {}

// UsersService залежить від UsersRepository, EmailService та LoggerService
@Injectable()
export class UsersService {
  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly emailService: EmailService,
    private readonly logger: LoggerService
  ) {}
}

Крок 3: Топологічне сортування

DI-контейнер виконує топологічне сортування (topological sort) графу залежностей, визначаючи правильний порядок створення екземплярів. Провайдери без залежностей створюються першими, після чого створюються провайдери, що залежать від уже ініціалізованих.

Крок 4: Створення екземплярів

NestJS викликає конструктори провайдерів у визначеному порядку, передаючи необхідні залежності:

  1. Створюється LoggerService (немає залежностей)
  2. Створюється EmailService (немає залежностей)
  3. Створюється UsersRepository (немає залежностей)
  4. Створюється UsersService (отримує три попередні сервіси)

Крок 5: Кешування екземплярів

Створені екземпляри зберігаються у DI-контейнері як singleton (один екземпляр на застосунок). При наступних запитах на ін'єкцію того самого провайдера контейнер повертає вже створений екземпляр, а не створює новий.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

package "Етап 1: Аналіз модуля" #F1F5F9 {
  rectangle "UsersModule" as Module #DBEAFE
  note right of Module
    @Module({
      providers: [
        UsersService,
        UsersRepository,
        EmailService,
        LoggerService
      ]
    })
  end note
}

package "Етап 2: Граф залежностей" #E0E7FF {
  rectangle "LoggerService\n(без залежностей)" as Logger #DCFCE7
  rectangle "EmailService\n(без залежностей)" as Email #DCFCE7
  rectangle "UsersRepository\n(без залежностей)" as Repo #DCFCE7
  rectangle "UsersService" as Users #C7D2FE
  
  Users --> Logger : залежить
  Users --> Email : залежить
  Users --> Repo : залежить
}

package "Етап 3: Порядок ініціалізації" #DCFCE7 {
  rectangle "1. new LoggerService()" as Step1 #BBF7D0
  rectangle "2. new EmailService()" as Step2 #BBF7D0
  rectangle "3. new UsersRepository()" as Step3 #BBF7D0
  rectangle "4. new UsersService(\n    logger,\n    email,\n    repo\n)" as Step4 #A7F3D0
  
  Step1 --> Step2
  Step2 --> Step3
  Step3 --> Step4
}

Module --> Logger
Module --> Email
Module --> Repo
Module --> Users

@enduml
DI-контейнер NestJS використовує метадані TypeScript для визначення типів параметрів конструктора. Для цього у tsconfig.json має бути увімкнена опція emitDecoratorMetadata: true. NestJS CLI автоматично налаштовує цю опцію при створенні проєкту, тому ви можете покластися на автоматичне розв'язання залежностей без додаткових налаштувань.

Порядок створення екземплярів: від залежностей до споживачів

Розуміння порядку створення екземплярів є важливим для усвідомлення життєвого циклу застосунку та діагностики проблем ініціалізації. NestJS завжди дотримується правила: залежності створюються раніше, ніж споживачі цих залежностей.

Розглянемо складніший приклад ієрархії залежностей:

// logger.service.ts
@Injectable()
export class LoggerService {
  constructor() {
    console.log('1. LoggerService створено');
  }
}

// config.service.ts
@Injectable()
export class ConfigService {
  constructor(private readonly logger: LoggerService) {
    console.log('2. ConfigService створено');
    this.logger.log('ConfigService ініціалізовано');
  }
}

// database.service.ts
@Injectable()
export class DatabaseService {
  constructor(
    private readonly config: ConfigService,
    private readonly logger: LoggerService
  ) {
    console.log('3. DatabaseService створено');
    const dbUrl = this.config.get('DATABASE_URL');
    this.logger.log(`Підключення до БД: ${dbUrl}`);
  }
}

// users.repository.ts
@Injectable()
export class UsersRepository {
  constructor(
    private readonly database: DatabaseService,
    private readonly logger: LoggerService
  ) {
    console.log('4. UsersRepository створено');
    this.logger.log('UsersRepository готовий до роботи');
  }
}

// users.service.ts
@Injectable()
export class UsersService {
  constructor(
    private readonly repository: UsersRepository,
    private readonly logger: LoggerService
  ) {
    console.log('5. UsersService створено');
    this.logger.log('UsersService ініціалізовано');
  }
}

При запуску застосунку консоль виведе:

npm run start
[Nest] 12345 - 04.09.2026, 15:30:00 LOG [NestFactory] Starting Nest application...
1. LoggerService створено
2. ConfigService створено
[LoggerService] ConfigService ініціалізовано
3. DatabaseService створено
[LoggerService] Підключення до БД: postgresql://localhost/mydb
4. UsersRepository створено
[LoggerService] UsersRepository готовий до роботи
5. UsersService створено
[LoggerService] UsersService ініціалізовано
[Nest] 12345 - 04.09.2026, 15:30:01 LOG [NestApplication] Nest application successfully started

Порядок ініціалізації чітко відображає граф залежностей:

  1. LoggerService створюється першим, оскільки не має залежностей
  2. ConfigService отримує LoggerService та ініціалізується
  3. DatabaseService отримує ConfigService та LoggerService
  4. UsersRepository отримує DatabaseService та LoggerService
  5. UsersService отримує UsersRepository та LoggerService і створюється останнім
Якщо під час ініціалізації провайдера виникне помилка (наприклад, виняток у конструкторі), застосунок не запуститься. NestJS зупинить процес завантаження та виведе повідомлення про помилку. Це захисний механізм, що запобігає запуску застосунку у неповністю ініціалізованому стані.

Циклічні залежності: проблема та попередження

Циклічна залежність (circular dependency) виникає, коли два або більше класів залежать один від одного, утворюючи замкнене коло. Наприклад, ServiceA залежить від ServiceB, а ServiceB залежить від ServiceA. Така ситуація створює парадокс: неможливо створити ServiceA, не створивши спочатку ServiceB, але неможливо створити ServiceB, не створивши спочатку ServiceA.

Приклад циклічної залежності

// users.service.ts
@Injectable()
export class UsersService {
  constructor(private readonly postsService: PostsService) {}

  async getUserWithPosts(userId: number) {
    const user = await this.findById(userId);
    const posts = await this.postsService.findByUserId(userId);
    return { ...user, posts };
  }
}

// posts.service.ts
@Injectable()
export class PostsService {
  constructor(private readonly usersService: UsersService) {}

  async getPostWithAuthor(postId: number) {
    const post = await this.findById(postId);
    const author = await this.usersService.findById(post.authorId);
    return { ...post, author };
  }
}

У цьому прикладі UsersService залежить від PostsService, а PostsService залежить від UsersService, створюючи циклічну залежність.

Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "UsersService" as Users #FCA5A5
rectangle "PostsService" as Posts #FCA5A5

Users -right-> Posts : залежить
Posts -left-> Users : залежить

note bottom of Users #FEF3C7
  Неможливо створити UsersService,
  оскільки потрібен PostsService
end note

note bottom of Posts #FEF3C7
  Неможливо створити PostsService,
  оскільки потрібен UsersService
end note

@enduml

Детектування циклічних залежностей

При спробі запуску застосунку з циклічною залежністю NestJS згенерує виняток:

npm run start
[Nest] 12345 - 04.09.2026, 15:30:00 ERROR [ExceptionHandler] Nest cannot create the UsersService instance.
The module at index [0] of the UsersService "imports" array is undefined.
Potential causes:
- A circular dependency between modules. Use forwardRef() to avoid it.
- The module at index [0] is of type "undefined". Check your import statements and the type of the module.

Способи уникнення циклічних залежностей

Існує кілька архітектурних підходів до вирішення проблеми циклічних залежностей:

1. Рефакторинг через винесення спільної логіки

Найкращий спосіб — переглянути архітектуру та винести спільну логіку в окремий сервіс:

// user-post.service.ts - Новий сервіс-оркестратор
@Injectable()
export class UserPostService {
  constructor(
    private readonly usersService: UsersService,
    private readonly postsService: PostsService
  ) {}

  async getUserWithPosts(userId: number) {
    const user = await this.usersService.findById(userId);
    const posts = await this.postsService.findByUserId(userId);
    return { ...user, posts };
  }

  async getPostWithAuthor(postId: number) {
    const post = await this.postsService.findById(postId);
    const author = await this.usersService.findById(post.authorId);
    return { ...post, author };
  }
}

// users.service.ts - Більше не залежить від PostsService
@Injectable()
export class UsersService {
  constructor(private readonly usersRepository: UsersRepository) {}

  async findById(userId: number) {
    return this.usersRepository.findById(userId);
  }
}

// posts.service.ts - Більше не залежить від UsersService
@Injectable()
export class PostsService {
  constructor(private readonly postsRepository: PostsRepository) {}

  async findById(postId: number) {
    return this.postsRepository.findById(postId);
  }

  async findByUserId(userId: number) {
    return this.postsRepository.findByUserId(userId);
  }
}

2. Використання подієвої архітектури (Event-driven)

Замість прямих викликів між сервісами використовуйте події:

// users.service.ts
@Injectable()
export class UsersService {
  constructor(private readonly eventEmitter: EventEmitter2) {}

  async createUser(userData: any) {
    const user = await this.usersRepository.create(userData);
    
    // Емітуємо подію замість прямого виклику PostsService
    this.eventEmitter.emit('user.created', { userId: user.id });
    
    return user;
  }
}

// posts.service.ts
@Injectable()
export class PostsService {
  @OnEvent('user.created')
  async handleUserCreated(payload: { userId: number }) {
    // Реагуємо на подію без прямої залежності від UsersService
    await this.createWelcomePost(payload.userId);
  }
}

3. Відкладена ін'єкція через forwardRef() (останній засіб)

NestJS надає механізм forwardRef() для роботи з циклічними залежностями, проте це слід використовувати лише як тимчасове рішення:

import { Injectable, Inject, forwardRef } from '@nestjs/common';

// users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @Inject(forwardRef(() => PostsService))
    private readonly postsService: PostsService
  ) {}
}

// posts.service.ts
@Injectable()
export class PostsService {
  constructor(
    @Inject(forwardRef(() => UsersService))
    private readonly usersService: UsersService
  ) {}
}
Використання forwardRef() є антипатерном та свідчить про проблеми в архітектурі застосунку. Циклічні залежності ускладнюють тестування, підтримку та розуміння потоку даних. Завжди намагайтеся усунути циклічні залежності через рефакторинг архітектури, а не обходити їх технічними прийомами.

Приклад: сервіс залежить від репозиторію та логера

Для закріплення матеріалу розглянемо повний приклад сервісу з двома залежностями — репозиторієм для доступу до даних та логером для запису подій:

// logger.service.ts
import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common';

@Injectable()
export class LoggerService implements NestLoggerService {
  log(message: string, context?: string) {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] [${context || 'App'}] ${message}`);
  }

  error(message: string, trace?: string, context?: string) {
    const timestamp = new Date().toISOString();
    console.error(`[${timestamp}] [${context || 'App'}] ERROR: ${message}`);
    if (trace) console.error(trace);
  }

  warn(message: string, context?: string) {
    const timestamp = new Date().toISOString();
    console.warn(`[${timestamp}] [${context || 'App'}] WARN: ${message}`);
  }

  debug(message: string, context?: string) {
    const timestamp = new Date().toISOString();
    console.debug(`[${timestamp}] [${context || 'App'}] DEBUG: ${message}`);
  }

  verbose(message: string, context?: string) {
    const timestamp = new Date().toISOString();
    console.log(`[${timestamp}] [${context || 'App'}] VERBOSE: ${message}`);
  }
}
// users.repository.ts
import { Injectable, NotFoundException } from '@nestjs/common';

interface User {
  id: number;
  email: string;
  name: string;
  createdAt: Date;
}

@Injectable()
export class UsersRepository {
  private users: User[] = [];
  private currentId = 1;

  async create(userData: Omit<User, 'id' | 'createdAt'>): Promise<User> {
    const user: User = {
      id: this.currentId++,
      ...userData,
      createdAt: new Date()
    };
    this.users.push(user);
    return user;
  }

  async findById(id: number): Promise<User | null> {
    return this.users.find(u => u.id === id) || null;
  }

  async findByEmail(email: string): Promise<User | null> {
    return this.users.find(u => u.email === email) || null;
  }

  async findAll(): Promise<User[]> {
    return this.users;
  }

  async update(id: number, userData: Partial<User>): Promise<User> {
    const user = await this.findById(id);
    if (!user) {
      throw new NotFoundException(`Користувача з ID ${id} не знайдено`);
    }
    Object.assign(user, userData);
    return user;
  }

  async delete(id: number): Promise<void> {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) {
      throw new NotFoundException(`Користувача з ID ${id} не знайдено`);
    }
    this.users.splice(index, 1);
  }
}
// users.service.ts
import { Injectable, ConflictException, NotFoundException } from '@nestjs/common';
import { UsersRepository } from './users.repository';
import { LoggerService } from '../logger/logger.service';

interface CreateUserDto {
  email: string;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly context = 'UsersService';

  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly logger: LoggerService
  ) {
    this.logger.log('UsersService ініціалізовано', this.context);
  }

  async create(createUserDto: CreateUserDto) {
    this.logger.log(`Спроба створення користувача: ${createUserDto.email}`, this.context);

    // Перевірка унікальності email
    const existingUser = await this.usersRepository.findByEmail(createUserDto.email);
    if (existingUser) {
      this.logger.warn(
        `Спроба створити користувача з існуючим email: ${createUserDto.email}`,
        this.context
      );
      throw new ConflictException('Користувач з таким email вже існує');
    }

    // Створення користувача
    const user = await this.usersRepository.create(createUserDto);
    this.logger.log(
      `Користувача успішно створено: ${user.email} (ID: ${user.id})`,
      this.context
    );

    return user;
  }

  async findAll() {
    this.logger.debug('Запит на отримання всіх користувачів', this.context);
    const users = await this.usersRepository.findAll();
    this.logger.log(`Знайдено користувачів: ${users.length}`, this.context);
    return users;
  }

  async findOne(id: number) {
    this.logger.debug(`Пошук користувача з ID: ${id}`, this.context);
    const user = await this.usersRepository.findById(id);

    if (!user) {
      this.logger.warn(`Користувача з ID ${id} не знайдено`, this.context);
      throw new NotFoundException(`Користувача з ID ${id} не знайдено`);
    }

    return user;
  }

  async update(id: number, updateUserDto: Partial<CreateUserDto>) {
    this.logger.log(`Оновлення користувача з ID: ${id}`, this.context);

    // Перевірка існування користувача
    await this.findOne(id); // Викине NotFoundException, якщо не знайдено

    // Перевірка унікальності email, якщо він змінюється
    if (updateUserDto.email) {
      const existingUser = await this.usersRepository.findByEmail(updateUserDto.email);
      if (existingUser && existingUser.id !== id) {
        this.logger.warn(
          `Спроба змінити email на вже існуючий: ${updateUserDto.email}`,
          this.context
        );
        throw new ConflictException('Email вже використовується іншим користувачем');
      }
    }

    const updatedUser = await this.usersRepository.update(id, updateUserDto);
    this.logger.log(`Користувача ${id} успішно оновлено`, this.context);
    return updatedUser;
  }

  async remove(id: number) {
    this.logger.log(`Видалення користувача з ID: ${id}`, this.context);

    // Перевірка існування
    await this.findOne(id);

    await this.usersRepository.delete(id);
    this.logger.log(`Користувача ${id} успішно видалено`, this.context);
  }
}

У цьому прикладі UsersService демонструє кілька важливих практик:

  1. Ін'єкція двох залежностей через конструктор
  2. Логування всіх важливих операцій для моніторингу та налагодження
  3. Чітке розділення відповідальностей: сервіс містить бізнес-логіку, репозиторій — доступ до даних
  4. Обробка помилок з відповідним логуванням
  5. Контекстне логування через властивість context для легкої ідентифікації джерела логів

Приклад: контролер залежить від кількох сервісів

Контролери, як координатори HTTP-запитів, часто залежать від кількох сервісів для реалізації складних ендпоінтів. Розглянемо приклад контролера автентифікації, що використовує три сервіси:

// auth.controller.ts
import {
  Controller,
  Post,
  Get,
  Body,
  UseGuards,
  Request,
  HttpCode,
  HttpStatus
} from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { AuthService } from './auth.service';
import { LoggerService } from '../logger/logger.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

interface RegisterDto {
  email: string;
  name: string;
  password: string;
}

interface LoginDto {
  email: string;
  password: string;
}

@Controller('auth')
export class AuthController {
  private readonly context = 'AuthController';

  constructor(
    private readonly usersService: UsersService,
    private readonly authService: AuthService,
    private readonly logger: LoggerService
  ) {
    this.logger.log('AuthController ініціалізовано', this.context);
  }

  @Post('register')
  async register(@Body() registerDto: RegisterDto) {
    this.logger.log(`Реєстрація нового користувача: ${registerDto.email}`, this.context);

    // Створення користувача через UsersService
    const user = await this.usersService.create({
      email: registerDto.email,
      name: registerDto.name
    });

    // Хешування пароля через AuthService
    await this.authService.setPassword(user.id, registerDto.password);

    // Генерація JWT-токена
    const token = await this.authService.generateToken(user);

    this.logger.log(`Користувача ${user.email} успішно зареєстровано`, this.context);

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      },
      token
    };
  }

  @Post('login')
  @HttpCode(HttpStatus.OK)
  async login(@Body() loginDto: LoginDto) {
    this.logger.log(`Спроба входу: ${loginDto.email}`, this.context);

    // Валідація облікових даних через AuthService
    const user = await this.authService.validateUser(loginDto.email, loginDto.password);

    // Генерація токена
    const token = await this.authService.generateToken(user);

    this.logger.log(`Успішний вхід: ${user.email}`, this.context);

    return {
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      },
      token
    };
  }

  @Get('profile')
  @UseGuards(JwtAuthGuard)
  async getProfile(@Request() req) {
    const userId = req.user.id;
    this.logger.debug(`Запит профілю користувача: ${userId}`, this.context);

    // Отримання даних користувача через UsersService
    const user = await this.usersService.findOne(userId);

    return {
      id: user.id,
      email: user.email,
      name: user.name
    };
  }

  @Post('logout')
  @UseGuards(JwtAuthGuard)
  @HttpCode(HttpStatus.NO_CONTENT)
  async logout(@Request() req) {
    const userId = req.user.id;
    this.logger.log(`Вихід користувача: ${userId}`, this.context);

    // Можна додати логіку інвалідації токена
    await this.authService.revokeToken(req.user.token);

    return;
  }
}

Цей контролер демонструє:

  1. Ін'єкцію трьох сервісів: UsersService, AuthService, LoggerService
  2. Координацію операцій: контролер викликає методи різних сервісів у правильній послідовності
  3. Логування всіх дій для аудиту та моніторингу
  4. Делегування бізнес-логіки: контролер не містить складної логіки, а делегує її сервісам

Тестування: легке мокування залежностей

Одна з найбільших переваг ін'єкції залежностей — це простота тестування. Оскільки залежності передаються ззовні через конструктор, під час тестування ми можемо легко замінити реальні провайдери на mock-об'єкти (фіктивні об'єкти), що імітують їх поведінку.

Приклад unit-тесту сервісу

// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { ConflictException, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersRepository } from './users.repository';
import { LoggerService } from '../logger/logger.service';

describe('UsersService', () => {
  let service: UsersService;
  let repository: UsersRepository;
  let logger: LoggerService;

  // Mock-об'єкти для залежностей
  const mockRepository = {
    create: jest.fn(),
    findById: jest.fn(),
    findByEmail: jest.fn(),
    findAll: jest.fn(),
    update: jest.fn(),
    delete: jest.fn()
  };

  const mockLogger = {
    log: jest.fn(),
    error: jest.fn(),
    warn: jest.fn(),
    debug: jest.fn(),
    verbose: jest.fn()
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: UsersRepository,
          useValue: mockRepository // Підміна реального репозиторію на mock
        },
        {
          provide: LoggerService,
          useValue: mockLogger // Підміна реального логера на mock
        }
      ]
    }).compile();

    service = module.get<UsersService>(UsersService);
    repository = module.get<UsersRepository>(UsersRepository);
    logger = module.get<LoggerService>(LoggerService);
  });

  afterEach(() => {
    // Очищення mock-викликів після кожного тесту
    jest.clearAllMocks();
  });

  describe('create', () => {
    it('має успішно створити користувача', async () => {
      // Arrange: підготовка даних
      const createUserDto = { email: 'test@example.com', name: 'Тест' };
      const createdUser = { id: 1, ...createUserDto, createdAt: new Date() };

      mockRepository.findByEmail.mockResolvedValue(null); // Email вільний
      mockRepository.create.mockResolvedValue(createdUser);

      // Act: виконання операції
      const result = await service.create(createUserDto);

      // Assert: перевірка результатів
      expect(result).toEqual(createdUser);
      expect(mockRepository.findByEmail).toHaveBeenCalledWith(createUserDto.email);
      expect(mockRepository.create).toHaveBeenCalledWith(createUserDto);
      expect(mockLogger.log).toHaveBeenCalledWith(
        expect.stringContaining('успішно створено'),
        'UsersService'
      );
    });

    it('має викинути ConflictException, якщо email вже існує', async () => {
      // Arrange
      const createUserDto = { email: 'existing@example.com', name: 'Тест' };
      mockRepository.findByEmail.mockResolvedValue({ id: 1, ...createUserDto });

      // Act & Assert
      await expect(service.create(createUserDto)).rejects.toThrow(ConflictException);
      expect(mockRepository.create).not.toHaveBeenCalled();
      expect(mockLogger.warn).toHaveBeenCalled();
    });
  });

  describe('findOne', () => {
    it('має повернути користувача за ID', async () => {
      // Arrange
      const user = { id: 1, email: 'test@example.com', name: 'Тест', createdAt: new Date() };
      mockRepository.findById.mockResolvedValue(user);

      // Act
      const result = await service.findOne(1);

      // Assert
      expect(result).toEqual(user);
      expect(mockRepository.findById).toHaveBeenCalledWith(1);
    });

    it('має викинути NotFoundException, якщо користувача не знайдено', async () => {
      // Arrange
      mockRepository.findById.mockResolvedValue(null);

      // Act & Assert
      await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
      expect(mockLogger.warn).toHaveBeenCalled();
    });
  });
});

Переваги мокування залежностей

Цей підхід до тестування надає кілька критичних переваг:

  1. Ізоляція: тестуємо лише логіку UsersService, без залежності від реалізації UsersRepository чи LoggerService
  2. Швидкість: тести виконуються миттєво, оскільки не потребують реального підключення до бази даних
  3. Контроль: можемо симулювати будь-які сценарії, включаючи помилки та крайні випадки
  4. Надійність: тести не падають через проблеми з зовнішніми системами (БД, мережа)
Використовуйте бібліотеки на кшталт jest для автоматичного створення mock-об'єктів. Функція jest.fn() створює mock-функцію, яка відстежує всі виклики та дозволяє налаштовувати повернені значення через mockResolvedValue() або mockReturnValue().

Best practices: явне оголошення залежностей у конструкторі

Для забезпечення читабельності, підтримуваності та тестованості коду дотримуйтесь наступних найкращих практик при роботі з ін'єкцією залежностей:

1. Завжди використовуйте private readonly

// ✅ ДОБРЕ
constructor(
  private readonly usersService: UsersService,
  private readonly logger: LoggerService
) {}

// ❌ ПОГАНО: без readonly
constructor(
  private usersService: UsersService,
  private logger: LoggerService
) {}

// ❌ ПОГАНО: без модифікатора доступу
constructor(
  readonly usersService: UsersService,
  readonly logger: LoggerService
) {}

2. Групуйте залежності логічно

// ✅ ДОБРЕ: логічне групування
constructor(
  // Основні бізнес-залежності
  private readonly usersRepository: UsersRepository,
  private readonly authService: AuthService,
  
  // Інфраструктурні сервіси
  private readonly logger: LoggerService,
  private readonly config: ConfigService
) {}

3. Уникайте занадто багатьох залежностей

// ⚠️ ПОГАНО: забагато залежностей (порушення SRP)
constructor(
  private readonly usersRepo: UsersRepository,
  private readonly postsRepo: PostsRepository,
  private readonly commentsRepo: CommentsRepository,
  private readonly likesRepo: LikesRepository,
  private readonly emailService: EmailService,
  private readonly smsService: SmsService,
  private readonly pushService: PushService,
  private readonly logger: LoggerService
) {}

// ✅ ДОБРЕ: розділіть на кілька сервісів

4. Не використовуйте ін'єкцію властивостей

NestJS підтримує ін'єкцію через властивості, проте це антипатерн:

// ❌ ПОГАНО: property-based injection
@Injectable()
export class UsersService {
  @Inject(UsersRepository)
  private repository: UsersRepository;
}

// ✅ ДОБРЕ: constructor-based injection
@Injectable()
export class UsersService {
  constructor(private readonly repository: UsersRepository) {}
}

5. Документуйте складні залежності

@Injectable()
export class OrdersService {
  /**
   * OrdersService координує роботу кількох підсистем:
   * - ProductsService: валідація наявності товарів
   * - PaymentService: обробка платежів
   * - EmailService: відправка підтверджень
   * - LoggerService: аудит операцій
   */
  constructor(
    private readonly productsService: ProductsService,
    private readonly paymentService: PaymentService,
    private readonly emailService: EmailService,
    private readonly logger: LoggerService
  ) {}
}

Резюме

Практика ін'єкції залежностей є фундаментальним навиком для розробки NestJS-застосунків. Ключові тези лекції:

✅ Механіка DI

  • Ін'єкція відбувається через конструктор класу
  • Використовуйте private readonly для автоматичного створення властивостей
  • DI-контейнер автоматично розв'язує залежності на основі типів
  • Порядок створення: від залежностей до споживачів
  • Singleton-патерн за замовчуванням

⚠️ Важливі застереження

  • Уникайте циклічних залежностей через рефакторинг
  • Не використовуйте forwardRef() без крайньої необхідності
  • Обмежуйте кількість залежностей (5-7 максимум)
  • Завжди реєструйте провайдери у модулях
  • Використовуйте mock-об'єкти для тестування

У наступній лекції ми розглянемо Custom Providers — спеціалізовані способи реєстрації провайдерів через useValue, useClass, useFactory та useExisting, що дозволяють створювати складні конфігурації та динамічно налаштовувати залежності.

Copyright © 2026