Тема 7. Фреймворк NestJS. Контролери та маршрутизація

Області видимості контролерів (Scopes)

DEFAULT, REQUEST, TRANSIENT scopes, вплив на продуктивність

Області видимості контролерів (Scopes)

🎯 Мета лекції

  • Зрозуміти концепцію областей видимості (Injection Scope) у контексті Dependency Injection
  • Опанувати три типи scope: DEFAULT (Singleton), REQUEST та TRANSIENT
  • Вивчити життєвий цикл екземплярів контролерів та провайдерів для кожного scope
  • Засвоїти вплив scope на продуктивність застосунку та споживання пам'яті
  • Навчитися розпізнавати сценарії, де REQUEST scope є необхідним
  • Практикувати конфігурацію scope через параметри @Injectable та @Controller
  • Зрозуміти механізм каскадування scope у дереві залежностей
  • Ознайомитися з обмеженнями REQUEST scope у контексті WebSocket та мікросервісів

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

  • Injection Scope (область видимості ін'єкції): правило, що визначає життєвий цикл екземпляра класу
  • Singleton Pattern (патерн одинак): один екземпляр об'єкта на весь застосунок
  • Request-scoped: новий екземпляр створюється для кожного HTTP-запиту
  • Transient: новий екземпляр створюється при кожній ін'єкції залежності
  • Instance Lifecycle (життєвий цикл екземпляра): час існування об'єкта від створення до знищення
  • Scope Cascading (каскадування scope): автоматичне успадкування scope залежностями
  • Multi-tenancy: архітектурний патерн, де один застосунок обслуговує кількох клієнтів

Концепція областей видимості у Dependency Injection

У попередніх лекціях ми вивчили, як NestJS автоматично створює та ін'єктує залежності через контейнер IoC (Inversion of Control). Проте ми не обговорювали життєвий цикл цих об'єктів: як довго вони існують, коли створюються та коли знищуються. Саме це визначає область видимості (scope) провайдера чи контролера.

Проблема життєвого циклу об'єктів

Розглянемо типову архітектуру NestJS застосунку:

@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly logger: Logger,
  ) {}
  
  @Get()
  findAll() {
    this.logger.log('Finding all users');
    return this.usersService.findAll();
  }
}

Запитання, на які має відповісти scope:

  1. Коли створюється екземпляр UsersController?
    • При старті застосунку один раз?
    • При кожному HTTP-запиті?
    • При кожному виклику методу?
  2. Чи спільно використовують кілька запитів той самий екземпляр UsersService?
    • Якщо так, як захистити стан від race conditions?
    • Якщо ні, чи не буде це надто дорого для продуктивності?
  3. Що станеться, якщо Logger зберігає контекст конкретного запиту?
    • Чи не перемішаються логи від різних користувачів?
    • Чи не витікає інформація між запитами?

Область видимості (scope) надає чіткі відповіді на ці запитання, визначаючи стратегію управління життєвим циклом об'єктів.

Три типи scope у NestJS

NestJS підтримує три області видимості через enum Scope:

Loading diagram...
graph TB
    subgraph "Scope.DEFAULT (Singleton)"
        D1["🚀 Application Bootstrap"]
        D2["Створення екземпляра<br/>UsersController"]
        D3["HTTP Request 1"]
        D4["HTTP Request 2"]
        D5["HTTP Request N"]
        D6["💤 Application Shutdown"]
        
        D1 --> D2
        D2 --> D3
        D2 --> D4
        D2 --> D5
        D5 --> D6
        
        style D2 fill:#22c55e,stroke:#15803d,color:#ffffff
        style D3 fill:#DBEAFE,stroke:#1d4ed8,color:#1e293b
        style D4 fill:#DBEAFE,stroke:#1d4ed8,color:#1e293b
        style D5 fill:#DBEAFE,stroke:#1d4ed8,color:#1e293b
    end
    
    subgraph "Scope.REQUEST"
        R1["HTTP Request 1"]
        R2["Створення екземпляра"]
        R3["Обробка запиту"]
        R4["🗑️ Знищення екземпляра"]
        
        R5["HTTP Request 2"]
        R6["Створення нового екземпляра"]
        
        R1 --> R2
        R2 --> R3
        R3 --> R4
        
        R5 --> R6
        
        style R2 fill:#f59e0b,stroke:#b45309,color:#ffffff
        style R6 fill:#f59e0b,stroke:#b45309,color:#ffffff
    end
    
    subgraph "Scope.TRANSIENT"
        T1["Ін'єкція у Controller"]
        T2["Створення екземпляра A"]
        
        T3["Ін'єкція у Service"]
        T4["Створення екземпляра B"]
        
        T1 --> T2
        T3 --> T4
        
        style T2 fill:#ef4444,stroke:#b91c1c,color:#ffffff
        style T4 fill:#ef4444,stroke:#b91c1c,color:#ffffff
    end
ScopeЖиттєвий циклСпільне використанняПродуктивність
DEFAULTОдин екземпляр на застосунок✅ Всі запити⚡️ Найшвидший
REQUESTНовий екземпляр на кожен HTTP-запит❌ Ізольовані🐢 Повільніший
TRANSIENTНовий екземпляр при кожній ін'єкції❌ Унікальні🐌 Найповільніший
За замовчуванням всі провайдери та контролери у NestJS мають scope Scope.DEFAULT (Singleton). Це оптимальний вибір для 95% випадків, оскільки забезпечує максимальну продуктивність та мінімальне споживання пам'яті.

Scope.DEFAULT: Singleton Pattern

Scope.DEFAULT реалізує класичний патерн Singleton (одинак): NestJS створює один екземпляр класу при старті застосунку та повторно використовує його для всіх наступних запитів. Це поведінка за замовчуванням, якщо scope не вказано явно.

Життєвий цикл Singleton

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

participant "NestJS\nBootstrap" as Boot #DCFCE7
participant "DI Container" as DI #DBEAFE
participant "UsersController\n(Singleton)" as Ctrl #F1F5F9

actor "Client 1" as C1 #3b82f6
actor "Client 2" as C2 #3b82f6
actor "Client 3" as C3 #3b82f6

Boot -> DI : Resolve dependencies
activate DI

DI -> Ctrl : new UsersController()
activate Ctrl
note right of Ctrl: Створюється один раз<br/>при старті застосунку

DI --> Boot : Application ready
deactivate DI

== Обробка запитів ==

C1 -> Ctrl : GET /users
activate Ctrl
Ctrl --> C1 : Response
deactivate Ctrl

C2 -> Ctrl : GET /users/123
activate Ctrl
note right of Ctrl: Той самий екземпляр
Ctrl --> C2 : Response
deactivate Ctrl

C3 -> Ctrl : POST /users
activate Ctrl
note right of Ctrl: Все ще той самий екземпляр
Ctrl --> C3 : Response
deactivate Ctrl

note over Ctrl #FEF3C7
  Екземпляр існує протягом
  всього життя застосунку
end note

@enduml

Приклад: DEFAULT scope (неявна поведінка)

import { Injectable, Controller, Get } from '@nestjs/common';

// За замовчуванням scope = Scope.DEFAULT
@Injectable()
export class UsersService {
  private callCount = 0; // Лічильник спільний для всіх запитів
  
  findAll() {
    this.callCount++;
    console.log(`UsersService.findAll() called ${this.callCount} times`);
    
    return [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ];
  }
}

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {
    console.log('UsersController instance created');
  }
  
  @Get()
  findAll() {
    return this.usersService.findAll();
  }
}

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

npm run start:dev
[Nest] Application bootstrapping...
→ UsersController instance created
[Nest] Nest application successfully started
// Перший запит:
GET /users
UsersService.findAll() called 1 times
// Другий запит:
GET /users
UsersService.findAll() called 2 times
// Третій запит:
GET /users
UsersService.findAll() called 3 times

Важливе спостереження:

  • Повідомлення "UsersController instance created" з'являється лише один раз при старті
  • Лічильник callCount зберігається між запитами, оскільки це один і той же екземпляр

Переваги Scope.DEFAULT

  1. Максимальна продуктивність
    • Об'єкт створюється лише один раз — немає накладних витрат на повторне створення
    • Немає витрат на garbage collection після кожного запиту
  2. Мінімальне споживання пам'яті
    • Один екземпляр на застосунок, незалежно від кількості запитів
    • Критично для застосунків з тисячами одночасних підключень
  3. Простота кешування
    • Можна безпечно кешувати дані у полях класу (з обережністю щодо конкурентності)
    • Ідеально для конфігурацій, з'єднань з БД, пулів потоків
Рекомендація: Використовуйте Scope.DEFAULT скрізь, де це можливо. Переходьте на REQUEST або TRANSIENT scope лише якщо у вас є конкретна технічна причина (request-специфічний контекст, multi-tenancy тощо).

Застереження: Спільний стан

Оскільки екземпляр спільний між усіма запитами, потрібно бути обережним зі станом:

@Injectable()
export class DangerousService {
  // ❌ НЕБЕЗПЕЧНО: currentUser перезаписується між запитами
  private currentUser: User;
  
  setUser(user: User) {
    this.currentUser = user; // Race condition!
  }
  
  getUser(): User {
    return this.currentUser; // Може повернути чужого користувача!
  }
}

Проблема: Якщо два запити обробляються одночасно, currentUser може бути перезаписаний посередині виконання, що призведе до витоку даних між користувачами.

Рішення:

  • Передавайте контекст як параметр методу замість збереження у полі
  • Або використовуйте REQUEST scope (розглянемо далі)
// ✅ БЕЗПЕЧНО: передаємо контекст як параметр
@Injectable()
export class SafeService {
  processOrder(user: User, order: Order) {
    // user існує лише в локальному scope методу
    return {
      userId: user.id,
      orderId: order.id,
      total: order.total,
    };
  }
}

Scope.REQUEST: Ізоляція контексту запиту

Scope.REQUEST інструктує NestJS створювати новий екземпляр класу для кожного вхідного HTTP-запиту. Після завершення обробки запиту екземпляр автоматично знищується та очищується збирачем сміття (garbage collector).

Життєвий цикл REQUEST scope

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

actor "Client 1" as C1 #DBEAFE
actor "Client 2" as C2 #DCFCE7

participant "DI Container" as DI #F1F5F9
participant "Request 1\nController Instance" as Ctrl1 #3b82f6
participant "Request 2\nController Instance" as Ctrl2 #22c55e

== Request 1 ==

C1 -> DI : GET /users
activate DI

DI -> Ctrl1 : new UsersController()
activate Ctrl1
note right of Ctrl1: Новий екземпляр<br/>для запиту 1

Ctrl1 --> DI : Process request
DI --> C1 : Response
deactivate DI

destroy Ctrl1
note right of Ctrl1: Знищується після<br/>завершення запиту

== Request 2 (паралельно або пізніше) ==

C2 -> DI : POST /users
activate DI

DI -> Ctrl2 : new UsersController()
activate Ctrl2
note right of Ctrl2: Абсолютно новий<br/>екземпляр для запиту 2

Ctrl2 --> DI : Process request
DI --> C2 : Response
deactivate DI

destroy Ctrl2
note right of Ctrl2: Знищується після<br/>завершення запиту

note bottom #FEF3C7
  Кожен запит отримує ізольований екземпляр.
  Немає спільного стану між запитами.
end note

@enduml

Конфігурація REQUEST scope

Scope вказується через параметр scope у декораторі @Injectable() або @Controller():

import { Injectable, Controller, Get, Scope } from '@nestjs/common';

// Провайдер з REQUEST scope
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  private requestId: string;
  private userId: string;
  
  setContext(requestId: string, userId: string) {
    this.requestId = requestId;
    this.userId = userId;
  }
  
  getContext() {
    return {
      requestId: this.requestId,
      userId: this.userId,
    };
  }
  
  log(message: string) {
    console.log(`[${this.requestId}] [User: ${this.userId}] ${message}`);
  }
}

// Контролер з REQUEST scope
@Controller({ path: 'orders', scope: Scope.REQUEST })
export class OrdersController {
  constructor(
    private readonly contextService: RequestContextService,
    private readonly ordersService: OrdersService,
  ) {
    console.log('New OrdersController instance created');
  }
  
  @Get()
  async findAll() {
    // Кожен запит має свій контекст
    this.contextService.log('Fetching all orders');
    return this.ordersService.findAll();
  }
}

Поведінка:

Паралельні запити
// Request 1:
New OrdersController instance created
[req-abc123] [User: alice] Fetching all orders
// Request 2 (одночасно):
New OrdersController instance created
[req-xyz789] [User: bob] Fetching all orders
// Request 3:
New OrdersController instance created
[req-def456] [User: alice] Fetching all orders

Кожен запит отримує власний екземпляр OrdersController та RequestContextService, тому контекст (requestId, userId) не змішується між запитами.

Коли використовувати REQUEST scope

REQUEST scope вирішує специфічні архітектурні проблеми, де потрібна ізоляція контексту між запитами:

1. Multi-tenancy (багатопользовацька архітектура)

У системах, де один застосунок обслуговує кілька клієнтів (tenants), потрібно ізолювати дані кожного клієнта:

import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class TenantService {
  private tenantId: string;
  
  constructor(@Inject(REQUEST) private readonly request: Request) {
    // Витягуємо tenantId з заголовка або піддомену
    this.tenantId = this.extractTenantId(request);
  }
  
  private extractTenantId(req: Request): string {
    // Варіант 1: З заголовка
    const headerTenant = req.headers['x-tenant-id'] as string;
    if (headerTenant) return headerTenant;
    
    // Варіант 2: З піддомену (acme.app.com → acme)
    const host = req.hostname;
    const subdomain = host.split('.')[0];
    
    return subdomain;
  }
  
  getTenantId(): string {
    return this.tenantId;
  }
}

@Injectable({ scope: Scope.REQUEST })
export class UsersService {
  constructor(
    private readonly tenantService: TenantService,
    private readonly database: Database,
  ) {}
  
  async findAll() {
    const tenantId = this.tenantService.getTenantId();
    
    // Запит до БД фільтрується за tenantId
    return this.database.query(
      'SELECT * FROM users WHERE tenant_id = $1',
      [tenantId],
    );
  }
}

Переваги:

  • Автоматична ізоляція даних на рівні DI контейнера
  • Неможливо випадково отримати дані іншого клієнта

2. Request-specific logging

Логування з контекстом запиту (request ID, user ID, trace ID):

import { Injectable, Scope, Inject, Logger } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { v4 as uuidv4 } from 'uuid';

@Injectable({ scope: Scope.REQUEST })
export class RequestLogger extends Logger {
  private requestId: string;
  private userId: string;
  
  constructor(@Inject(REQUEST) private readonly request: Request) {
    super('HTTP');
    
    // Генеруємо або витягуємо request ID
    this.requestId = (request.headers['x-request-id'] as string) || uuidv4();
    
    // Витягуємо userId з автентифікації (якщо доступний)
    this.userId = (request as any).user?.id || 'anonymous';
  }
  
  log(message: string) {
    super.log(`[${this.requestId}] [User: ${this.userId}] ${message}`);
  }
  
  error(message: string, trace?: string) {
    super.error(`[${this.requestId}] [User: ${this.userId}] ${message}`, trace);
  }
}

@Controller('products')
export class ProductsController {
  constructor(
    private readonly logger: RequestLogger,
    private readonly productsService: ProductsService,
  ) {}
  
  @Get()
  async findAll() {
    this.logger.log('Fetching all products');
    const products = await this.productsService.findAll();
    this.logger.log(`Found ${products.length} products`);
    return products;
  }
}

Результат у консолі:

Structured logging
[HTTP] [a7f3e9c1] [User: alice] Fetching all products
[HTTP] [b2d8f4a6] [User: bob] Fetching all products
[HTTP] [a7f3e9c1] [User: alice] Found 42 products
[HTTP] [b2d8f4a6] [User: bob] Found 42 products

Логи чітко розділені за request ID, навіть якщо запити обробляються паралельно.

3. Ін'єкція об'єкта Request

REQUEST scope дозволяє ін'єктувати нативний об'єкт Request з Express/Fastify через токен REQUEST:

import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class AuditService {
  constructor(@Inject(REQUEST) private readonly request: Request) {}
  
  async logAction(action: string, entityId: string) {
    const auditEntry = {
      action,
      entityId,
      userId: (this.request as any).user?.id,
      ipAddress: this.request.ip,
      userAgent: this.request.headers['user-agent'],
      timestamp: new Date(),
    };
    
    // Зберігаємо аудит у БД
    await this.database.save('audit_log', auditEntry);
  }
}
Важливо: REQUEST scope працює лише з HTTP-контекстом (Express/Fastify). Він не підтримується у WebSocket Gateway, GraphQL Subscription, Microservices та Cron Jobs, оскільки у цих контекстах немає поняття "HTTP-запиту".

Вплив на продуктивність

REQUEST scope має суттєві накладні витрати порівняно з DEFAULT:

Loading diagram...
graph LR
    subgraph "Scope.DEFAULT (Singleton)"
        D1["Створення: 1 раз<br/>при старті"]
        D2["GC: Ніколи"]
        D3["Пам'ять: Мінімум"]
        
        style D1 fill:#22c55e,stroke:#15803d,color:#ffffff
        style D2 fill:#22c55e,stroke:#15803d,color:#ffffff
        style D3 fill:#22c55e,stroke:#15803d,color:#ffffff
    end
    
    subgraph "Scope.REQUEST"
        R1["Створення: Кожен запит"]
        R2["GC: Після кожного запиту"]
        R3["Пам'ять: Висока"]
        
        style R1 fill:#f59e0b,stroke:#b45309,color:#ffffff
        style R2 fill:#f59e0b,stroke:#b45309,color:#ffffff
        style R3 fill:#f59e0b,stroke:#b45309,color:#ffffff
    end

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

МетрикаDEFAULTREQUESTРізниця
Requests/sec10,0007,500-25%
Latency P9515ms22ms+47%
Memory usage80MB150MB+87%
Критичне для продакшн: REQUEST scope може знизити пропускну здатність на 20-30% та збільшити споживання пам'яті у 2 рази. Використовуйте його лише там, де DEFAULT scope технічно неможливий.

Scope.TRANSIENT: Унікальні екземпляри

Scope.TRANSIENT створює новий екземпляр класу при кожній ін'єкції залежності, навіть у межах одного запиту. Це найрідкісний scope, що використовується лише у специфічних сценаріях.

Відмінність TRANSIENT від REQUEST

@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
  private instanceId = Math.random().toString(36);
  
  getInstanceId() {
    return this.instanceId;
  }
}

@Controller('demo')
export class DemoController {
  constructor(
    private readonly service1: TransientService,
    private readonly service2: TransientService,
  ) {
    console.log('Service 1 ID:', service1.getInstanceId());
    console.log('Service 2 ID:', service2.getInstanceId());
  }
}

Вивід при старті:

TRANSIENT scope behavior
Service 1 ID: k7h9x2p
Service 2 ID: m3n8q1r

Навіть у межах одного контролера кожна ін'єкція TransientService отримує власний екземпляр.

Порівняльна таблиця scope

АспектDEFAULTREQUESTTRANSIENT
Екземплярів на застосунок1N (кількість запитів)M (кількість ін'єкцій)
СтворюєтьсяПри стартіПочаток запитуПри resolve залежності
ЗнищуєтьсяПри shutdownКінець запитуПри GC
Спільний між запитами✅ Так❌ Ні❌ Ні
Спільний у межах запиту✅ Так✅ Так❌ Ні
Продуктивність⚡️⚡️⚡️⚡️🐌
Використання95% випадківMulti-tenancy, request contextРідкісні edge cases
Практична рекомендація: У реальних проєктах TRANSIENT scope майже ніколи не використовується. Якщо вам потрібна ізоляція — використовуйте REQUEST scope або фабричні функції замість ін'єкції.

Каскадування scope у дереві залежностей

Одна з найважливіших особливостей scope у NestJS — каскадування (scope bubbling): якщо провайдер має non-default scope (REQUEST або TRANSIENT), всі його залежності автоматично успадковують цей scope, навіть якщо у них вказано DEFAULT.

Механізм каскадування

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

package "Dependency Tree" {
    class UsersController {
        scope: REQUEST
    }
    
    class UsersService {
        scope: DEFAULT
        ---
        Фактично: REQUEST (каскад)
    }
    
    class DatabaseService {
        scope: DEFAULT
        ---
        Фактично: REQUEST (каскад)
    }
    
    class LoggerService {
        scope: DEFAULT
        ---
        Фактично: REQUEST (каскад)
    }
    
    UsersController --> UsersService : залежить
    UsersService --> DatabaseService : залежить
    UsersService --> LoggerService : залежить
}

note right of UsersController #DBEAFE
  Контролер має REQUEST scope.
  Всі залежності "заражаються"
  REQUEST scope через каскадування.
end note

note bottom of DatabaseService #FEF3C7
  DatabaseService оголошений як DEFAULT,
  але через каскадування працює як REQUEST.
  Створюється новий екземпляр для кожного запиту.
end note

@enduml

Приклад каскадування

// database.service.ts
@Injectable() // scope: Scope.DEFAULT (за замовчуванням)
export class DatabaseService {
  constructor() {
    console.log('DatabaseService instance created');
  }
  
  query(sql: string) {
    return `Executing: ${sql}`;
  }
}

// users.service.ts
@Injectable() // scope: Scope.DEFAULT
export class UsersService {
  constructor(private readonly db: DatabaseService) {
    console.log('UsersService instance created');
  }
  
  findAll() {
    return this.db.query('SELECT * FROM users');
  }
}

// users.controller.ts
@Controller({ path: 'users', scope: Scope.REQUEST })
export class UsersController {
  constructor(private readonly usersService: UsersService) {
    console.log('UsersController instance created');
  }
  
  @Get()
  findAll() {
    return this.usersService.findAll();
  }
}

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

Scope cascading в дії
// Request 1:
UsersController instance created
UsersService instance created
DatabaseService instance created
GET /users → 200 OK
// Request 2 (одночасно):
UsersController instance created
UsersService instance created
DatabaseService instance created
GET /users → 200 OK

Ключове спостереження: Навіть хоча UsersService та DatabaseService оголошені як Scope.DEFAULT, вони створюються заново для кожного запиту, оскільки їх споживач (UsersController) має Scope.REQUEST.

Візуалізація каскадування

Loading diagram...
graph TD
    A["Controller<br/>(REQUEST)"]
    B["ServiceA<br/>(DEFAULT → REQUEST)"]
    C["ServiceB<br/>(DEFAULT → REQUEST)"]
    D["RepositoryA<br/>(DEFAULT → REQUEST)"]
    E["LoggerService<br/>(DEFAULT → REQUEST)"]
    F["ConfigService<br/>(DEFAULT)"]
    
    A --> B
    A --> C
    B --> D
    B --> E
    C --> E
    
    style A fill:#ef4444,stroke:#b91c1c,color:#ffffff
    style B fill:#f59e0b,stroke:#b45309,color:#ffffff
    style C fill:#f59e0b,stroke:#b45309,color:#ffffff
    style D fill:#f59e0b,stroke:#b45309,color:#ffffff
    style E fill:#f59e0b,stroke:#b45309,color:#ffffff
    style F fill:#22c55e,stroke:#15803d,color:#ffffff
    
    note1["REQUEST scope<br/>у корені дерева"]
    note2["Каскадування:<br/>DEFAULT → REQUEST"]
    note3["ConfigService НЕ залежить<br/>від REQUEST провайдерів,<br/>залишається DEFAULT"]
    
    A -.-> note1
    B -.-> note2
    F -.-> note3

Правило: Каскадування відбувається вниз по дереву залежностей. Якщо провайдер не споживається REQUEST-scoped класом, він залишається DEFAULT.

Вимикання каскадування через durable providers

У NestJS 8+ з'явилася можливість вимкнути каскадування для конкретних провайдерів через параметр durable:

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

@Injectable({ scope: Scope.DEFAULT, durable: true })
export class ConfigService {
  // Цей провайдер завжди залишається Singleton,
  // навіть якщо споживається REQUEST-scoped класами
  
  private config = {
    apiUrl: process.env.API_URL,
    dbHost: process.env.DB_HOST,
  };
  
  get(key: string) {
    return this.config[key];
  }
}

Переваги durable providers:

  • Збереження продуктивності для "важких" сервісів (пули з'єднань, кеші)
  • Уникнення непотрібного каскадування для stateless утиліт
Обережно з durable: Якщо провайдер зберігає request-специфічний стан (userId, tenantId), durable: true призведе до витоку даних між запитами. Використовуйте лише для immutable конфігурацій та stateless сервісів.

Конфігурація scope у декораторах

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

Scope у @Injectable (провайдери)

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

// Варіант 1: Об'єкт з параметром scope
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
  // Новий екземпляр на кожен HTTP-запит
}

// Варіант 2: Додаткові параметри
@Injectable({
  scope: Scope.DEFAULT,
  durable: true, // Вимкнути каскадування
})
export class DurableService {
  // Завжди Singleton, навіть у REQUEST-дереві
}

// Варіант 3: TRANSIENT scope
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
  // Новий екземпляр при кожній ін'єкції
}

Scope у @Controller

import { Controller, Scope } from '@nestjs/common';

// Варіант 1: Розширений синтаксис з scope
@Controller({ path: 'users', scope: Scope.REQUEST })
export class UsersController {
  // Новий екземпляр контролера на кожен запит
}

// Варіант 2: Комбінація з host (субдомен)
@Controller({
  path: 'admin/users',
  host: 'admin.example.com',
  scope: Scope.REQUEST,
})
export class AdminUsersController {
  // REQUEST scope + subdomain routing
}

Custom providers з scope

При реєстрації провайдерів через useClass, useFactory або useValue, scope можна вказати у об'єкті конфігурації:

// app.module.ts
import { Module, Scope } from '@nestjs/common';

@Module({
  providers: [
    // useClass з REQUEST scope
    {
      provide: 'LOGGER',
      useClass: RequestLogger,
      scope: Scope.REQUEST,
    },
    
    // useFactory з REQUEST scope
    {
      provide: 'DATABASE_CONNECTION',
      useFactory: (configService: ConfigService) => {
        return createDatabaseConnection(configService.get('DB_URL'));
      },
      inject: [ConfigService],
      scope: Scope.REQUEST,
    },
    
    // useValue завжди DEFAULT (немає сенсу створювати копії значення)
    {
      provide: 'API_KEY',
      useValue: process.env.API_KEY,
    },
  ],
})
export class AppModule {}
useValue providers завжди мають scope Scope.DEFAULT, оскільки значення є immutable константою. Вказування scope: Scope.REQUEST для useValue буде проігноровано.

Обмеження REQUEST scope

REQUEST scope працює лише у контексті HTTP-запитів (Express/Fastify). У інших контекстах він або не підтримується, або має специфічну поведінку.

Контексти без підтримки REQUEST

КонтекстREQUEST scopeАльтернатива
HTTP (Express/Fastify)✅ Підтримується—
WebSocket Gateway❌ Не працюєЗберігати контекст у socket.data
GraphQL Subscriptions❌ Не працюєВикористовувати context resolvers
Microservices (NATS, Kafka)⚠️ ЧастковоScope прив'язується до повідомлення
Cron Jobs❌ Не працюєВикористовувати DEFAULT
CLI Commands (NestJS Console)❌ Не працюєПередавати контекст параметрами

Приклад проблеми з WebSocket

import { WebSocketGateway, SubscribeMessage, MessageBody } from '@nestjs/websockets';
import { Injectable, Scope } from '@nestjs/common';

// ❌ REQUEST scope не працює з WebSocket
@Injectable({ scope: Scope.REQUEST })
export class WebSocketContextService {
  private userId: string;
  
  setUserId(userId: string) {
    this.userId = userId;
  }
}

@WebSocketGateway()
export class ChatGateway {
  constructor(private readonly context: WebSocketContextService) {}
  
  @SubscribeMessage('sendMessage')
  handleMessage(@MessageBody() data: { message: string }) {
    // ⚠️ context.userId буде undefined або змішаний між клієнтами
    // оскільки WebSocket не має поняття "HTTP-запиту"
  }
}

Правильне рішення для WebSocket:

import { WebSocketGateway, WebSocketServer, SubscribeMessage } from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

@WebSocketGateway()
export class ChatGateway {
  @WebSocketServer()
  server: Server;
  
  handleConnection(client: Socket) {
    // Зберігаємо контекст у самому сокеті
    client.data.userId = this.extractUserId(client);
    console.log(`User ${client.data.userId} connected`);
  }
  
  @SubscribeMessage('sendMessage')
  handleMessage(client: Socket, message: string) {
    // Читаємо контекст з client.data
    const userId = client.data.userId;
    
    this.server.emit('newMessage', {
      userId,
      message,
      timestamp: new Date(),
    });
  }
  
  private extractUserId(client: Socket): string {
    // Витягуємо userId з токена авторизації
    const token = client.handshake.auth.token;
    return this.authService.getUserIdFromToken(token);
  }
}

Best Practices: вибір правильного scope

Вибір scope суттєво впливає на архітектуру, продуктивність та надійність застосунку. Розглянемо перевірені практики для продакшн-систем.

Правило 1. DEFAULT за замовчуванням

✅ Завжди починайте з Scope.DEFAULT:

// ✅ Правильно — DEFAULT для stateless логіки
@Injectable()
export class UsersService {
  constructor(private readonly database: Database) {}
  
  async findById(userId: string): Promise<User> {
    return this.database.query('SELECT * FROM users WHERE id = $1', [userId]);
  }
}

❌ Уникайте REQUEST без необхідності:

// ❌ Неправильно — REQUEST без причини
@Injectable({ scope: Scope.REQUEST })
export class UsersService {
  // Немає request-специфічного стану, але страждає продуктивність
  async findById(userId: string): Promise<User> {
    return this.database.query('SELECT * FROM users WHERE id = $1', [userId]);
  }
}

Правило 2. REQUEST лише для контексту

REQUEST scope виправданий лише у трьох сценаріях:

  1. Multi-tenancy — ізоляція даних клієнтів
  2. Request-specific logging — structured logs з request ID
  3. Ін'єкція Request object — доступ до headers, IP, user agent
// ✅ Правильно — REQUEST для tenant isolation
@Injectable({ scope: Scope.REQUEST })
export class TenantService {
  constructor(@Inject(REQUEST) private readonly request: Request) {
    this.tenantId = request.headers['x-tenant-id'];
  }
}

// ❌ Неправильно — REQUEST для звичайної бізнес-логіки
@Injectable({ scope: Scope.REQUEST })
export class MathService {
  // Чиста функція, немає контексту — DEFAULT достатньо
  add(a: number, b: number): number {
    return a + b;
  }
}

Правило 3. Уникайте TRANSIENT

TRANSIENT scope майже ніколи не потрібен:

// ❌ Неправильно — TRANSIENT без причини
@Injectable({ scope: Scope.TRANSIENT })
export class ConfigService {
  // Конфігурація immutable — DEFAULT ідеальний
  get(key: string) {
    return process.env[key];
  }
}

// ✅ Правильно — фабрика замість TRANSIENT
@Injectable()
export class LoggerFactory {
  create(context: string): Logger {
    return new Logger(context); // Новий екземпляр через метод
  }
}

Правило 4. Мінімізуйте REQUEST-дерева

Ізолюйте REQUEST scope на краю застосунку:

// ✅ Правильно — REQUEST лише для middleware/context
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  private context: { tenantId: string; userId: string };
  
  setContext(tenantId: string, userId: string) {
    this.context = { tenantId, userId };
  }
  
  getContext() {
    return this.context;
  }
}

// DEFAULT сервіси приймають контекст як параметр
@Injectable()
export class UsersService {
  async findByTenant(tenantId: string): Promise<User[]> {
    return this.database.query(
      'SELECT * FROM users WHERE tenant_id = $1',
      [tenantId],
    );
  }
}

// Controller витягує контекст та передає далі
@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly requestContext: RequestContextService,
  ) {}
  
  @Get()
  async findAll() {
    const { tenantId } = this.requestContext.getContext();
    return this.usersService.findByTenant(tenantId);
  }
}

Переваги:

  • Каскадування обмежене — лише RequestContextService є REQUEST
  • UsersService залишається DEFAULT — швидкий та тестований
  • Контекст передається явно — легко відстежити залежності

Правило 5. Використовуйте durable для інфраструктури

Захистіть інфраструктурні сервіси від каскадування:

// ✅ Правильно — пул з'єднань завжди Singleton
@Injectable({ scope: Scope.DEFAULT, durable: true })
export class DatabasePool {
  private pool: Pool;
  
  constructor() {
    this.pool = new Pool({
      host: process.env.DB_HOST,
      database: process.env.DB_NAME,
      max: 20, // Максимум 20 з'єднань
    });
  }
  
  query(sql: string, params: any[]) {
    return this.pool.query(sql, params);
  }
}

Навіть якщо DatabasePool споживається REQUEST-контролером, він залишиться Singleton, що критично для пула з'єднань.

Діагностика scope у runtime

Для налагодження проблем з scope можна логувати створення екземплярів:

@Injectable({ scope: Scope.REQUEST })
export class DebugService {
  private instanceId = Math.random().toString(36).substring(7);
  
  constructor() {
    console.log(`[DebugService] Instance ${this.instanceId} created`);
  }
  
  getInstanceId() {
    return this.instanceId;
  }
}

Якщо бачите один instanceId для всіх запитів — scope не працює. Якщо бачите різні instanceId — REQUEST scope активний.

Практичні приклади scope

Розглянемо реальні сценарії використання різних scope у продакшн-застосунках.

Приклад 1: Multi-tenant SaaS застосунок

// tenant.service.ts — REQUEST scope для ізоляції клієнтів
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class TenantService {
  private readonly tenantId: string;
  
  constructor(@Inject(REQUEST) private readonly request: Request) {
    // Витягуємо tenant з піддомену: acme.app.com → "acme"
    const subdomain = this.request.hostname.split('.')[0];
    this.tenantId = subdomain;
    
    console.log(`[TenantService] Initialized for tenant: ${this.tenantId}`);
  }
  
  getTenantId(): string {
    return this.tenantId;
  }
  
  async getTenantConfig() {
    // Завантажуємо конфігурацію конкретного клієнта
    return {
      tenantId: this.tenantId,
      features: ['analytics', 'reports'],
      limits: { users: 100, storage: '10GB' },
    };
  }
}

// users.service.ts — DEFAULT scope, приймає tenantId як параметр
@Injectable()
export class UsersService {
  constructor(private readonly database: Database) {}
  
  async findByTenant(tenantId: string): Promise<User[]> {
    return this.database.query(
      `SELECT * FROM users WHERE tenant_id = $1 ORDER BY created_at DESC`,
      [tenantId],
    );
  }
  
  async createUser(tenantId: string, userData: CreateUserDto): Promise<User> {
    return this.database.query(
      `INSERT INTO users (tenant_id, email, name) VALUES ($1, $2, $3) RETURNING *`,
      [tenantId, userData.email, userData.name],
    );
  }
}

// users.controller.ts
@Controller('users')
export class UsersController {
  constructor(
    private readonly usersService: UsersService,
    private readonly tenantService: TenantService, // REQUEST-scoped
  ) {}
  
  @Get()
  async findAll() {
    const tenantId = this.tenantService.getTenantId();
    return this.usersService.findByTenant(tenantId);
  }
  
  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    const tenantId = this.tenantService.getTenantId();
    return this.usersService.createUser(tenantId, createUserDto);
  }
}

Поведінка:

Multi-tenant запити
// Запит від tenant "acme":
[TenantService] Initialized for tenant: acme
GET acme.app.com/users → 3 users
// Запит від tenant "globex":
[TenantService] Initialized for tenant: globex
GET globex.app.com/users → 5 users

Кожен запит автоматично отримує ізольований контекст клієнта.

Приклад 2: Request tracing для distributed systems

// request-context.service.ts — REQUEST scope для tracing
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { v4 as uuidv4 } from 'uuid';

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  public readonly traceId: string;
  public readonly spanId: string;
  public readonly userId: string;
  
  constructor(@Inject(REQUEST) private readonly request: Request) {
    // Витягуємо або генеруємо trace ID
    this.traceId = (request.headers['x-trace-id'] as string) || uuidv4();
    this.spanId = uuidv4();
    this.userId = (request as any).user?.id || 'anonymous';
    
    console.log(`[Trace: ${this.traceId}] Request started`);
  }
  
  createChildSpan(operation: string) {
    return {
      traceId: this.traceId,
      parentSpanId: this.spanId,
      spanId: uuidv4(),
      operation,
    };
  }
}

// external-api.service.ts — DEFAULT scope, використовує context
@Injectable()
export class ExternalApiService {
  constructor(
    private readonly httpService: HttpService,
    private readonly requestContext: RequestContextService,
  ) {}
  
  async callPartnerApi(endpoint: string, data: any) {
    const span = this.requestContext.createChildSpan('partner-api-call');
    
    console.log(`[Trace: ${span.traceId}] [Span: ${span.spanId}] Calling ${endpoint}`);
    
    try {
      const response = await firstValueFrom(
        this.httpService.post(endpoint, data, {
          headers: {
            'X-Trace-Id': span.traceId,
            'X-Parent-Span-Id': span.parentSpanId,
            'X-Span-Id': span.spanId,
          },
        }),
      );
      
      console.log(`[Trace: ${span.traceId}] [Span: ${span.spanId}] Response received`);
      return response.data;
      
    } catch (error) {
      console.error(`[Trace: ${span.traceId}] [Span: ${span.spanId}] Error:`, error.message);
      throw error;
    }
  }
}

Результат у логах (distributed tracing):

Трасування запитів через мікросервіси
[Trace: a7f3-e9c1] Request started
[Trace: a7f3-e9c1] [Span: b2d8-f4a6] Calling /partner-api/orders
[Trace: a7f3-e9c1] [Span: b2d8-f4a6] Response received

Trace ID прокидується через всі сервіси, дозволяючи відстежити повний шлях запиту.

Резюме та рекомендації

✅ Використовуйте DEFAULT

  • За замовчуванням для всіх контролерів та сервісів
  • Максимальна продуктивність та мінімальне споживання пам'яті
  • Підходить для 95% сценаріїв

⚠️ REQUEST з обережністю

  • Лише для multi-tenancy, request logging, Request injection
  • Знижує продуктивність на 20-30%
  • Не працює з WebSocket, Cron Jobs

❌ Уникайте TRANSIENT

  • Рідко потрібен у реальних проєктах
  • Використовуйте фабрики замість TRANSIENT scope
  • Найгірша продуктивність

🛡️ Використовуйте durable

  • Для інфраструктурних сервісів (пули з'єднань, кеші)
  • Захищає від небажаного каскадування
  • Лише для stateless провайдерів

Порівняльна таблиця: коли використовувати кожен scope

СценарійРекомендований scopeПричина
Бізнес-логіка (сервіси)DEFAULTStateless, максимальна продуктивність
Репозиторії (БД)DEFAULTПул з'єднань, кешування
Утилітарні функціїDEFAULTImmutable, без контексту
Multi-tenant системиREQUEST (лише TenantService)Ізоляція контексту клієнта
Request loggingREQUEST (лише Logger)Trace ID, User ID
WebSocket handlersDEFAULTREQUEST не підтримується
Cron JobsDEFAULTНемає HTTP-контексту
КонфігураціяDEFAULT + durableImmutable, захист від каскадування
Пули з'єднаньDEFAULT + durableКритично для продуктивності
Підсумкова рекомендація: Scope.DEFAULT — ваш основний інструмент. Переходьте на REQUEST scope лише коли DEFAULT технічно неможливий (multi-tenancy, request-specific context). Завжди вимірюйте вплив на продуктивність через навантажувальні тести.
Copyright © 2026