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

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

Проблема циклічних залежностей, рішення через forwardRef

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

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

  • Що таке циклічна залежність (circular dependency)
  • Проблема: ModuleA залежить від ModuleB, який залежить від ModuleA
  • Помилка при запуску: "Cannot access X before initialization"
  • Функція forwardRef(): відкладене розв'язання залежності
  • Використання у декораторі @Module (imports)
  • Використання у параметрах конструктора (@Inject(forwardRef))
  • Приклад: UsersService ↔ AuthService взаємна залежність
  • Альтернативи: рефакторинг архітектури, винесення спільної логіки
  • Коли forwardRef неминучий: domain events, circular business logic
  • Best practices: уникати циклічних залежностей через перепроєктування

Концепція циклічної залежності

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

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

  1. Між модулями: UsersModule імпортує AuthModule, а AuthModule імпортує UsersModule
  2. Між провайдерами: UsersService залежить від AuthService, а AuthService залежить від UsersService

JavaScript/TypeScript не можуть вирішити такі залежності автоматично, оскільки для створення екземпляру A потрібен екземпляр B, але для створення B потрібен A — це неможлива ситуація без спеціальних механізмів.

🎯 Мета лекції

  • Зрозуміти природу циклічних залежностей та причини їх виникнення
  • Опанувати функцію forwardRef() для розв'язання циклів
  • Навчитися виявляти та діагностувати циклічні залежності
  • Дослідити альтернативні підходи до рефакторингу архітектури
  • Застосовувати best practices для запобігання циклічним залежностям

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

  • Circular Dependency — циклічна залежність, коли A залежить від B, а B залежить від A
  • forwardRef() — функція NestJS для відкладеного розв'язання циклічних залежностей
  • Dependency Graph — граф залежностей модулів та провайдерів
  • Forward Reference — посилання на клас, що буде визначений пізніше у коді
  • Architectural Smell — ознака проблеми у проєктуванні архітектури

Проблема: як виникають циклічні залежності

Розглянемо типовий сценарій виникнення циклічної залежності між модулями:

// users/users.module.ts
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';  // Імпорт AuthModule
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [AuthModule],  // UsersModule залежить від AuthModule
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService]
})
export class UsersModule {}

// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { UsersModule } from '../users/users.module';  // Імпорт UsersModule
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [UsersModule],  // AuthModule залежить від UsersModule
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService]
})
export class AuthModule {}

Візуалізація циклічної залежності:

Loading diagram...
flowchart LR
    A[UsersModule] -->|imports| B[AuthModule]
    B -->|imports| A
    
    style A fill:#ef4444,stroke:#991b1b,color:#ffffff
    style B fill:#ef4444,stroke:#991b1b,color:#ffffff
    
    note1[Цикл!<br/>Неможливо визначити<br/>порядок ініціалізації]
    
    A -.-> note1
    B -.-> note1

Помилка при запуску застосунку

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

npm run start:dev
$ npm run start:dev
Error: A circular dependency has been detected
between modules:
UsersModule → AuthModule
AuthModule → UsersModule
at checkCircularImport (nest/core/injector/module.js:84:15)
at InstanceLoader.createInstancesOfDependencies

Або у випадку циклічної залежності між провайдерами:

npm run start:dev
$ npm run start:dev
ReferenceError: Cannot access 'UsersService' before initialization
at Object. (auth/auth.service.ts:3:17)
at Module._compile (internal/modules/cjs/loader.js:1085:14)

Функція forwardRef(): відкладене розв'язання залежності

NestJS надає функцію forwardRef(), яка дозволяє створити forward reference — посилання на клас, що буде визначений пізніше. Це дозволяє розірвати цикл залежностей, відкладаючи розв'язання залежності до моменту, коли обидва класи будуть повністю завантажені.

Синтаксис forwardRef

import { forwardRef } from '@nestjs/common';

// У декораторі @Module
imports: [forwardRef(() => SomeModule)]

// У параметрах конструктора
constructor(@Inject(forwardRef(() => SomeService)) private service: SomeService) {}

Ключова ідея: замість прямого посилання на клас SomeModule ми передаємо функцію, що повертає клас. Ця функція викликається пізніше, коли клас вже визначений.


Використання forwardRef у декораторі @Module

Для розв'язання циклічної залежності між модулями потрібно використати forwardRef() в обох модулях:

// users/users.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';

@Module({
  imports: [
    forwardRef(() => AuthModule)  // ✅ Forward reference на AuthModule
  ],
  providers: [UsersService],
  controllers: [UsersController],
  exports: [UsersService]
})
export class UsersModule {}

// auth/auth.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';

@Module({
  imports: [
    forwardRef(() => UsersModule)  // ✅ Forward reference на UsersModule
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService]
})
export class AuthModule {}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

participant "NestJS Container" as Container #DBEAFE
participant "UsersModule" as Users #DCFCE7
participant "AuthModule" as Auth #E0E7FF

Container -> Users : Ініціалізація UsersModule
Users -> Container : imports: forwardRef(() => AuthModule)
note right
  Повертається функція,
  а не безпосереднє посилання
end note

Container -> Auth : Ініціалізація AuthModule
Auth -> Container : imports: forwardRef(() => UsersModule)
note right
  Повертається функція,
  а не безпосереднє посилання
end note

Container -> Container : Обидва модулі завантажені
Container -> Users : Виклик функції forwardRef
Users --> Container : Отримання AuthModule
Container -> Auth : Виклик функції forwardRef
Auth --> Container : Отримання UsersModule

note over Container
  Цикл розірвано через
  відкладене розв'язання
end note

@enduml
Важливо:forwardRef() потрібно використовувати в обох модулях, що утворюють цикл. Якщо застосувати його лише в одному модулі, помилка залишиться.

Використання forwardRef у параметрах конструктора

Циклічна залежність може виникнути не тільки між модулями, а й між провайдерами. Наприклад, UsersService потребує AuthService, а AuthService потребує UsersService:

// users/users.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';

@Injectable()
export class UsersService {
  constructor(
    @Inject(forwardRef(() => AuthService))  // ✅ Forward reference
    private readonly authService: AuthService
  ) {}

  async createUser(userData: any) {
    const user = await this.saveToDatabase(userData);
    
    // Використання AuthService
    await this.authService.sendVerificationEmail(user.email);
    
    return user;
  }

  async findByEmail(email: string) {
    // Логіка пошуку
  }

  private async saveToDatabase(userData: any) {
    // Збереження у БД
  }
}

// auth/auth.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
  constructor(
    @Inject(forwardRef(() => UsersService))  // ✅ Forward reference
    private readonly usersService: UsersService
  ) {}

  async validateUser(email: string, password: string) {
    // Використання UsersService
    const user = await this.usersService.findByEmail(email);
    
    if (user && await this.comparePasswords(password, user.password)) {
      return user;
    }
    
    return null;
  }

  async sendVerificationEmail(email: string) {
    // Відправка email
  }

  private async comparePasswords(plain: string, hashed: string) {
    // Порівняння паролів
  }
}
При використанні forwardRef() у конструкторах обов'язково потрібен декоратор @Inject(), оскільки TypeScript не може автоматично визначити тип параметра через forward reference.

Повний приклад: UsersModule ↔ AuthModule

Розглянемо повний приклад розв'язання циклічної залежності на всіх рівнях:

// users/users.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UsersRepository } from './users.repository';

@Module({
  imports: [
    forwardRef(() => AuthModule)  // ✅ Розв'язання циклу на рівні модулів
  ],
  providers: [UsersService, UsersRepository],
  controllers: [UsersController],
  exports: [UsersService]
})
export class UsersModule {}

// auth/auth.module.ts
import { Module, forwardRef } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { UsersModule } from '../users/users.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  imports: [
    forwardRef(() => UsersModule),  // ✅ Розв'язання циклу на рівні модулів
    PassportModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '1d' }
    })
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
  exports: [AuthService]
})
export class AuthModule {}

// users/users.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { AuthService } from '../auth/auth.service';
import { UsersRepository } from './users.repository';

@Injectable()
export class UsersService {
  constructor(
    private readonly usersRepository: UsersRepository,
    @Inject(forwardRef(() => AuthService))  // ✅ Розв'язання циклу на рівні провайдерів
    private readonly authService: AuthService
  ) {}

  async register(email: string, password: string) {
    // Перевірка унікальності
    const existingUser = await this.usersRepository.findByEmail(email);
    if (existingUser) {
      throw new Error('User already exists');
    }

    // Хешування пароля через AuthService
    const hashedPassword = await this.authService.hashPassword(password);

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

    return user;
  }

  async findByEmail(email: string) {
    return this.usersRepository.findByEmail(email);
  }
}

// auth/auth.service.ts
import { Injectable, Inject, forwardRef } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService,
    @Inject(forwardRef(() => UsersService))  // ✅ Розв'язання циклу на рівні провайдерів
    private readonly usersService: UsersService
  ) {}

  async validateUser(email: string, password: string) {
    // Отримання користувача через UsersService
    const user = await this.usersService.findByEmail(email);
    
    if (user && await bcrypt.compare(password, user.password)) {
      const { password, ...result } = user;
      return result;
    }
    
    return null;
  }

  async login(user: any) {
    const payload = { email: user.email, sub: user.id };
    return {
      access_token: this.jwtService.sign(payload)
    };
  }

  async hashPassword(password: string): Promise<string> {
    return bcrypt.hash(password, 10);
  }
}

Альтернативи forwardRef: рефакторинг архітектури

Хоча forwardRef() розв'язує технічну проблему циклічних залежностей, він є антипаттерном, що свідчить про проблеми у проєктуванні архітектури. У більшості випадків циклічні залежності можна уникнути через рефакторинг.

✅ Альтернатива 1: Винесення спільної логіки у третій модуль

Створіть окремий модуль для спільної функціональності:

// shared/password/password.module.ts
import { Module } from '@nestjs/common';
import { PasswordService } from './password.service';

@Module({
  providers: [PasswordService],
  exports: [PasswordService]
})
export class PasswordModule {}

// shared/password/password.service.ts
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';

@Injectable()
export class PasswordService {
  async hash(password: string): Promise<string> {
    return bcrypt.hash(password, 10);
  }

  async compare(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }
}

Тепер обидва модулі можуть імпортувати PasswordModule без циклічної залежності:

// users/users.module.ts
@Module({
  imports: [PasswordModule],  // ✅ Немає циклічної залежності
  providers: [UsersService],
  exports: [UsersService]
})
export class UsersModule {}

// auth/auth.module.ts
@Module({
  imports: [
    UsersModule,      // ✅ Односпрямована залежність
    PasswordModule    // ✅ Немає циклічної залежності
  ],
  providers: [AuthService],
  exports: [AuthService]
})
export class AuthModule {}

✅ Альтернатива 2: Подієва архітектура (Event-Driven)

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

// users/users.service.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';

@Injectable()
export class UsersService {
  constructor(private readonly eventEmitter: EventEmitter2) {}

  async register(email: string, password: string) {
    const user = await this.createUser(email, password);
    
    // Випускаємо подію замість прямого виклику AuthService
    this.eventEmitter.emit('user.registered', { userId: user.id, email: user.email });
    
    return user;
  }
}

// auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';

@Injectable()
export class AuthService {
  // Підписуємося на подію
  @OnEvent('user.registered')
  async handleUserRegistered(payload: { userId: number; email: string }) {
    // Відправка привітального email
    await this.sendVerificationEmail(payload.email);
  }
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

participant "UsersService" as Users #DCFCE7
participant "EventEmitter" as Events #FEF3C7
participant "AuthService" as Auth #E0E7FF

Users -> Users : register()
Users -> Events : emit('user.registered', data)
note right
  Немає прямої залежності
  від AuthService
end note

Events -> Auth : @OnEvent('user.registered')
Auth -> Auth : handleUserRegistered()
Auth -> Auth : sendVerificationEmail()

note over Users, Auth
  Слабка зв'язаність через події
end note

@enduml

✅ Альтернатива 3: Інверсія залежності через інтерфейси

Визначте інтерфейс у нейтральному місці та залежте від абстракції:

// common/interfaces/authentication.interface.ts
export interface IAuthenticationService {
  hashPassword(password: string): Promise<string>;
  validateUser(email: string, password: string): Promise<any>;
}

// users/users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @Inject('IAuthenticationService')
    private readonly authService: IAuthenticationService  // Залежність від інтерфейсу
  ) {}
}

// auth/auth.module.ts
@Module({
  providers: [
    {
      provide: 'IAuthenticationService',
      useClass: AuthService  // Реалізація інтерфейсу
    }
  ],
  exports: ['IAuthenticationService']
})
export class AuthModule {}

Коли forwardRef неминучий

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

Сценарій 1: Bidirectional Relations у Domain Models

// У системі з двосторонніми зв'язками між сутностями:
// User має багато Posts, Post належить User

// entities/user.entity.ts
@Entity()
export class User {
  @OneToMany(() => Post, post => post.author)
  posts: Post[];
}

// entities/post.entity.ts
@Entity()
export class Post {
  @ManyToOne(() => User, user => user.posts)
  author: User;
}

Сценарій 2: Plugin Systems

// Система плагінів, де кожен плагін може залежати від core та навпаки
Навіть у цих випадках спочатку розгляньте альтернативи рефакторингу. Використовуйте forwardRef() лише як останній засіб, коли всі інші підходи неможливі або надмірно складні.

Best Practices: уникання циклічних залежностей

🚫 Уникайте за замовчуванням

Принцип: Циклічні залежності — це architectural smell

Рекомендації:

  • Переглядайте архітектуру при виникненні циклів
  • Шукайте спільну логіку, що може бути винесена
  • Використовуйте подієву архітектуру для слабкої зв'язаності

📐 Дотримуйтесь принципу DIP

Dependency Inversion Principle: Залежте від абстракцій, а не реалізацій

Рекомендації:

  • Визначайте інтерфейси у нейтральних місцях
  • Використовуйте токени для ін'єкції
  • Уникайте прямих імпортів конкретних реалізацій

🔄 Використовуйте події

Event-Driven Architecture: Слабка зв'язаність через події

Рекомендації:

  • Застосовуйте EventEmitter2 для асинхронної комунікації
  • Підписуйтеся на події через @OnEvent()
  • Документуйте події як частину публічного API

🛠️ Рефакторинг > forwardRef

Принцип: forwardRef() — це технічне рішення, не архітектурне

Рекомендації:

  • Використовуйте forwardRef() лише як тимчасове рішення
  • Плануйте рефакторинг для усунення циклів
  • Документуйте причину використання forwardRef()

Висновки

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

  • Виявлення: NestJS автоматично виявляє циклічні залежності під час запуску
  • Технічне рішення: forwardRef() дозволяє розірвати цикл через відкладене розв'язання
  • Архітектурне рішення: рефакторинг через винесення спільної логіки, події або інверсію залежностей
  • Best practice: уникайте циклічних залежностей через правильне проєктування архітектури
forwardRef() — це костиль, а не рішення. Якщо ви використовуєте його, це сигнал про необхідність переглянути архітектуру застосунку.

У наступній лекції ми розглянемо Lifecycle Hooks — механізми для виконання коду на різних етапах життєвого циклу модулів та провайдерів.

Copyright © 2026