Модули, области и контекст запроса

Nest≈ 17 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Границы у вашего приложения уже есть: модуль с providers, exports и imports решает, кто кого видит. В настоящем проекте к этим трём спискам добавляется папка, а рядом с ними — два механизма, которых у вас не было: модуль, видный всем без единого импорта, и модуль, который собирают на ходу. Но главное в этой статье — третье, чего в вашем каркасе нет вовсе: область. До сих пор провайдер жил в одном экземпляре, а значит, обслуживал все запросы сразу; Nest умеет создавать его на каждый запрос — и берёт за это плату, о которой в документации сказано одной строкой. Сегодня вы увидите, как общее поле отдаёт Ане заметку Бори, почему область заразна снизу вверх и что вместо неё делает хранилище, привязанное к цепочке вызовов.

Разделы темы:·

У вас модуль — это три списка в декораторе. В живом проекте к ним прирастает соглашение о файлах: модуль — папка раздела, и в ней лежит всё, что к разделу относится. Вот корень боевого API учебника — того самого, чей контроллер загрузки вы разбирали в первой статье главы.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Первое, что стоит заметить, — корень не делает ничего сам: ни одного провайдера, ни одного контроллера, только список. Два первых пункта в нём — динамические модули, до них дойдём в третьем разделе; остальные шесть — обычные классы с @Module, и каждому соответствует папка в src: auth, notes, upload, health и configs, где живёт общее — база и транспорт. Раздел, папка и модуль здесь одно и то же, и по imports корня читается состав приложения.

Внутри раздела списки короткие. Три модуля из боевого API целиком:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Заметки не экспортируют ничего: снаружи в них никто не ходит, а три класса, которые им нужны внутри, друг друга видят и так. Почта экспортирует MailService, и AuthModule, которому надо слать письма о сбросе пароля, импортирует MailModule — то самое лицо модуля из вашей модели. А вот AuthModule экспортирует AuthService, которого снаружи сейчас не просит никто: AuthRouter лежит в том же модуле и достаёт его как своего. Лишний экспорт ничего не ломает, но и не значит ничего — кроме обещания соседям, что этот класс можно брать. Правило простое: в exports попадает то, чем уже пользуется сосед, а не то, чем он мог бы воспользоваться.

Репозиторий не экспортирован нигде, и это та же граница, которую вы проводили руками. Соблазн вписать чужой класс себе в providers здесь ровно так же наказуем: это даёт второй объект, а не доступ к первому. С сервисом без состояния второй объект незаметен, с пулом соединений — это второй пул.

Модуль — не папка. Но в живом проекте они совпадают, и когда перестают совпадать, это первый признак, что раздел пора делить.

Цену нетранзитивных импортов вы знаете: модуль видит экспорты только тех, кого импортировал сам. Для домена это правильно — список imports читается как полный перечень того, на что раздел опирается. Для базы и настроек это навязчиво: строка imports: [DrizzleModule] появляется в каждом модуле приложения и ничего о нём не сообщает.

На этот случай есть пометка @Global(). Так подключена база в боевом API:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В этом коротком классе три разные вещи, и их стоит развести. @Global() — то самое ослабление границы: Nest дописывает такой модуль в imports каждого модуля приложения. exports при этом остаются лицом: наружу открыт один токен DrizzleAsyncProvider, а не всё, что внутри, и токен этот — символ, потому что интерфейс ключом контейнера быть не может. А onApplicationShutdown — хук жизненного цикла: закрыть пул, когда приложение гасят. По сигналу его, впрочем, не позовут, пока в main.ts нет строки app.enableShutdownHooks(): сигналы Nest слушает только после неё. Об этой строке и обо всей остановке речь впереди.

Проверим границу глобального модуля на трёх корнях сразу. ClockModule помечен @Global() и экспортирует Clock, но не PoolSecret. NotesModule и MailModule не импортируют вообще ничего, PeekModule просит внутренний PoolSecret. Прогноз: какие из трёх приложений соберутся и одни ли часы достанутся заметкам и почте?

прогон node 26.5.0 · 22 сентября 2026
Три корня: с глобальным модулем в imports, без него и с просьбой о неэкспортированном провайдере
import 'reflect-metadata';
import { Global, Injectable, Module, type Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

@Injectable()
class Clock {}

@Injectable()
class PoolSecret {}

// exports по-прежнему лицо модуля: PoolSecret в них не попал
@Global()
@Module({ providers: [Clock, PoolSecret], exports: [Clock] })
class ClockModule {}

@Injectable()
class NotesService { constructor(readonly clock: Clock) {} }

@Injectable()
class MailService { constructor(readonly clock: Clock) {} }

@Injectable()
class PeekService { constructor(readonly secret: PoolSecret) {} }

// ни один из трёх ClockModule не импортирует
@Module({ providers: [NotesService], exports: [NotesService] })
class NotesModule {}

@Module({ providers: [MailService], exports: [MailService] })
class MailModule {}

@Module({ providers: [PeekService] })
class PeekModule {}

// каждый корень ниже — своё приложение и свой набор связей
@Module({ imports: [ClockModule, NotesModule, MailModule] })
class RootImportsClock {}

@Module({ imports: [NotesModule, MailModule] })
class RootForgotClock {}

@Module({ imports: [ClockModule, PeekModule] })
class RootPeeksInside {}

async function собрать(root: Type) {
  try {
    // abortOnError: false — иначе Nest при ошибке сборки гасит процесс, а не бросает исключение
    const app = await NestFactory.createApplicationContext(root, { logger: false, abortOnError: false });
    const notes = app.get(NotesService);
    const mail = app.get(MailService);
    console.log(`${root.name}: создано, часы у заметок и почты одни: ${notes.clock === mail.clock}`);
    await app.close();
  } catch (err) {
    console.log(`${root.name}: ${(err as Error).constructor.name}`);
  }
}

await собрать(RootImportsClock);
await собрать(RootForgotClock);
await собрать(RootPeeksInside);
nest start

Первое приложение собралось, и Clock у заметок и почты один — в этом весь эффект пометки: экспорты модуля видны тем, кто о нём не просил, а объект по-прежнему один, потому что владелец у токена один. Второе приложение упало UnknownDependenciesException, хотя ClockModule помечен глобальным: пометка расширяет видимость, но не заводит модуль. Модуль, до которого не дойти по imports от корня, в приложении не существует — это уже говорилось там, где вы проводили границы руками, и пометка этого правила не отменяет. Поэтому глобальный модуль всё равно импортируют один раз, обычно в корневом. Третье упало по той же причине, по какой падало бы и без глобальности: PoolSecret есть в providers, но не в exports, и наружу его не вынесла никакая пометка.

Платят за это невидимой зависимостью. В @Module раздела, который работает с базой, о базе не сказано ни слова: чтобы узнать, откуда у репозитория соединение, придётся открыть корень. Пока глобальных модулей два-три и все они инфраструктурные — настройки, база, логгер, — это терпимо, в боевом API учебника глобальных ровно два. Как только глобальным становится доменный модуль, приложение перестаёт разбираться на части: собрать раздел в тесте отдельно или вынести его в другой сервис уже нельзя — он тянет за собой корень.

Глобальность — это про видимость, а не про существование. Импортировать модуль хотя бы раз всё равно надо.

Списки в @Module посчитаны один раз — тогда, когда объявлен класс. А модулю базы нужен адрес, модулю почты — хост SMTP, и приходят они в рантайме. Отсюда второй способ ослабить статичность: метод, который возвращает описание модуля, — объект DynamicModule с полями module, providers, exports, imports.

Имена у таких методов по всей экосистеме одни и те же, и договор за ними стоит простой. forRoot — настройка один раз на приложение (так подключены ConfigModule и ThrottlerModule в корне боевого API), register — то же самое там, где слово «корень» ни при чём, forFeature — добавка для одного раздела, а вариант с Async в имени — когда настройки сами приезжают из провайдера. Ничего волшебного в этих именах нет: это обычные статические методы, и Nest о них не знает — он видит только объект, который они вернули.

В прогоне ниже SettingsModule.forRoot(адрес) возвращает такое описание с провайдером Settings. Три корня собирают одно и то же тремя способами: в первом заметки и почта зовут forRoot каждый у себя, с одинаковым адресом; во втором forRoot позвали один раз, и оба модуля ввозят один и тот же объект; в третьем — один раз в корне и с global: true. Прогноз: сколько объектов Settings окажется в каждом приложении?

прогон node 26.5.0 · 22 сентября 2026
Один и тот же модуль настроек, собранный тремя способами: свой вызов у каждого, общий объект и глобальный в корне
import 'reflect-metadata';
import { Inject, Injectable, Module, type DynamicModule, type Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

const SETTINGS_URL = 'SETTINGS_URL';
let объектовSettings = 0;

@Injectable()
class Settings {
  constructor(@Inject(SETTINGS_URL) readonly url: string) { объектовSettings++; }
}

@Module({}) // пусто: провайдеры и экспорт приедут из forRoot
class SettingsModule {
  // обычный статический метод: он возвращает описание модуля, а не модуль
  static forRoot(url: string, глобальный = false): DynamicModule {
    return {
      module: SettingsModule,
      global: глобальный,
      providers: [{ provide: SETTINGS_URL, useValue: url }, Settings],
      exports: [Settings],
    };
  }
}

@Injectable()
class NotesService { constructor(readonly settings: Settings) {} }

@Injectable()
class MailService { constructor(readonly settings: Settings) {} }

// вариант 1: каждый модуль зовёт forRoot сам, адрес у обоих одинаковый
@Module({ imports: [SettingsModule.forRoot('postgres://app')], providers: [NotesService], exports: [NotesService] })
class NotesModuleOwn {}

@Module({ imports: [SettingsModule.forRoot('postgres://app')], providers: [MailService], exports: [MailService] })
class MailModuleOwn {}

// вариант 2: forRoot позвали один раз, и оба модуля ввозят один и тот же объект
const настройки = SettingsModule.forRoot('postgres://app');

@Module({ imports: [настройки], providers: [NotesService], exports: [NotesService] })
class NotesModuleShared {}

@Module({ imports: [настройки], providers: [MailService], exports: [MailService] })
class MailModuleShared {}

// вариант 3: forRoot один раз в корне и global: true — модулям ввозить нечего
@Module({ providers: [NotesService], exports: [NotesService] })
class NotesModuleBare {}

@Module({ providers: [MailService], exports: [MailService] })
class MailModuleBare {}

@Module({ imports: [NotesModuleOwn, MailModuleOwn] })
class RootOwnCalls {}

@Module({ imports: [NotesModuleShared, MailModuleShared] })
class RootSharedObject {}

@Module({ imports: [SettingsModule.forRoot('postgres://app', true), NotesModuleBare, MailModuleBare] })
class RootGlobalSettings {}

async function собрать(root: Type) {
  объектовSettings = 0;
  try {
    const app = await NestFactory.createApplicationContext(root, { logger: false, abortOnError: false });
    const notes = app.get(NotesService);
    const mail = app.get(MailService);
    console.log(`${root.name}: объектов Settings — ${объектовSettings}, у заметок и почты он один: ${notes.settings === mail.settings}`);
    await app.close();
  } catch (err) {
    console.log(`${root.name}: ${(err as Error).constructor.name}`);
  }
}

await собрать(RootOwnCalls);
await собрать(RootSharedObject);
await собрать(RootGlobalSettings);
nest start

Два вызова с одинаковым адресом дали два модуля и два объекта Settings. Это главная ловушка динамических модулей: Nest 12 различает описания по ссылке на объект, а не по его содержимому, и дословно совпавшие аргументы не делают два описания одним модулем. Для настроек лишний объект безобиден. Для пула соединений это два пула вместо одного, то есть вдвое больше сессий в базе; для ограничителя частоты со счётчиком в памяти — два счётчика, каждый из которых видит половину запросов.

Оба лечения — в том же прогоне. Позвать forRoot один раз и ввезти полученный объект туда, где он нужен: описание становится общим, и модуль один. Или позвать его один раз в корне и пометить global: true — в описании это то же, что @Global() над классом, и остальным модулям ввозить вообще ничего не надо. Второй путь выбран у настроек боевого API: ConfigModule.forRoot({ isGlobal: true }) в корне, и дальше ConfigService внедряют где угодно.

Настройки базы сами зависят от настроек приложения: адрес лежит в ConfigService. В боевом API это решено на уровне провайдера — фабрикой со списком того, что ей внедрить:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

inject — те же токены, что и у конструктора, только перечисленные руками, а useFactory может вернуть обещание: Nest дождётся его, прежде чем отдавать значение потребителям. Методы forRootAsync и registerAsync чужих пакетов устроены так же, только фабрику вы передаёте им аргументом, а провайдер они описывают у себя внутри.

forRoot — статический метод. Позовёте дважды — получите два модуля, даже если аргументы совпали до буквы.

Всё, что вы собирали до сих пор, живёт в одном экземпляре: объект на токен, и этот объект обслуживает все запросы сразу — на сервере их тысяча на один поток. Пока сервис держит только зависимости и методы, это правильно и дёшево. Беда начинается, когда в него кладут поле.

Ниже «текущая заявка» в поле одиночки: прослойка записывает туда имя пользователя, репозиторий читает. Две заявки приходят одна за другой, а база отвечает им в обратном порядке — так и бывает, когда одному запросу ответил кеш, а другой ждёт диска. Никаких потоков здесь нет: обе заявки живут в одном, и между ними стоит обычный await. Прогноз: что получит каждая и что останется в поле?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Аня получила чужую заметку, и не абы какую. Разбор дословный: Аня записала себя в поле и ушла ждать базу; пока она ждала, Боря переписал поле собой, дождался своего ответа и ушёл; когда база ответила Ане, в поле стоял уже Боря. await — это пауза внутри функции, а не пауза процесса: пока одна заявка ждёт, вторая успевает выполниться целиком.

Утечка эта тем противнее, что на машине разработчика её не видно: пока запрос в системе один, поле всегда «правильное». Ловят её на боевом сервере, по жалобе «мне показали чужое», и по логам она не читается — в них не видно, чей await чей.

Nest отвечает на это областью провайдера. Область — ответ на вопрос «сколько объектов», и вариантов три:

ОбластьСколько объектовКогда нужна
DEFAULT, по умолчаниюодин на приложениепочти всегда: сервис без состояния, репозиторий, настройки
REQUESTпо одному на каждый входящий запросв объекте лежат данные заявки: пользователь, язык, открытая транзакция
TRANSIENTсвой каждому, кто его внедряетсостояние привязано к потребителю: логгер, который знает имя своего класса

Объявляется она аргументом декоратора: @Injectable({ scope: Scope.REQUEST }). Поле такого провайдера принадлежит одному запросу, и переписать его некому. Заодно Nest даёт и саму заявку: по-запросный провайдер может внедрить токен REQUEST из @nestjs/core и получить объект запроса платформы — у Fastify это FastifyRequest с заголовками и телом. Выглядит это как решение всех проблем сразу. Следующий раздел — о том, во что оно обходится.

Поле в одиночке — это поле, общее всем запросам. Прежде чем его завести, спросите, чьё оно.

Пусть NotesService внедряет по-запросный CurrentUser. Сколько объектов NotesService должно быть в приложении? Одним он быть не может: в его поле лежала бы ссылка на пользователя одного-единственного запроса — ровно та утечка, от которой область и спасает. Значит, сервис тоже по-запросный. А контроллер, который внедряет сервис, — тоже. Это и называется заразностью: область поднимается от зависимости к потребителю, и никто из них её не объявлял.

Добавим правило в модель контейнера: оно короче, чем кажется. Область класса своя, если объявлена, иначе самая узкая из областей его зависимостей; по-запросные объекты кешируются не в контейнере, а в карте заявки, которая живёт ровно один запрос. Прогноз: какие классы окажутся по-запросными и что создаст второй запрос?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Правило уместилось в две строки функции, а последствия — во всей колонке. Db и NotesRepo остались одиночками: они ни от кого по-запросного не зависят, и второй запрос их не трогал. HealthController тоже одиночка — он просит только репозиторий. А NotesService и NotesController стали по-запросными, хотя в их объявлениях об области нет ни слова, и второй запрос построил их заново вместе с CurrentUser.

Теперь те же классы в настоящем Nest, и с двумя добавками. Logger объявлен Scope.TRANSIENT и внедрён в репозиторий и в контроллер здоровья. Metrics — обычный одиночка, RequestLog — по-запросный, и не внедрён никуда ни тот ни другой. Счётчик в конструкторе каждого класса печатает, сколько раз его создавали. Прогноз: что будет создано к концу старта, что добавят два запроса к /notes и два к /health и на каких токенах упадёт app.get?

прогон node 26.5.0 · 22 сентября 2026
Счётчики конструкторов на старте, после четырёх запросов и после двух resolve; app.get на шести токенах
import 'reflect-metadata';
import { Controller, Get, Inject, Injectable, Module, Scope, type Type } from '@nestjs/common';
import { NestFactory, REQUEST } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';

// счётчик вызовов конструктора: он и показывает, что пересобирается
const создано: Record<string, number> = {};
const новый = (имя: string) => (создано[имя] = (создано[имя] ?? 0) + 1);

@Injectable({ scope: Scope.TRANSIENT }) // свой объект каждому, кто внедряет
class Logger {
  constructor() { новый('Logger'); }
}

@Injectable()
class NotesRepo {
  constructor(private readonly log: Logger) { новый('NotesRepo'); }
  readonly rows = ['аня: купить хлеб', 'боря: пароль от почты'];
}

@Injectable() // одиночка, которого никто не внедряет
class Metrics {
  constructor() { новый('Metrics'); }
}

@Injectable({ scope: Scope.REQUEST }) // по-запросный, которого никто не внедряет
class RequestLog {
  constructor() { новый('RequestLog'); }
}

@Injectable({ scope: Scope.REQUEST })
class CurrentUser {
  constructor(@Inject(REQUEST) private readonly request: { headers?: Record<string, string | undefined> }) {
    новый('CurrentUser');
  }
  get name() { return this.request.headers?.['x-user'] ?? 'гость'; }
}

@Injectable() // область не объявлена
class NotesService {
  constructor(readonly repo: NotesRepo, readonly user: CurrentUser) { новый('NotesService'); }
  mine() { return this.repo.rows.filter((row) => row.startsWith(`${this.user.name}:`)); }
}

@Controller('notes') // и здесь не объявлена
class NotesController {
  constructor(private readonly notes: NotesService) { новый('NotesController'); }
  @Get()
  list() { return this.notes.mine(); }
}

@Controller('health')
class HealthController {
  constructor(private readonly repo: NotesRepo, private readonly log: Logger) { новый('HealthController'); }
  @Get()
  ok() { return { notes: this.repo.rows.length }; }
}

@Module({
  controllers: [NotesController, HealthController],
  providers: [NotesRepo, NotesService, CurrentUser, Metrics, RequestLog, Logger],
})
class AppModule {}

// abortOnError: false — иначе исключение из app.get уходит в ExceptionsZone и процесс гибнет молча
const app = await NestFactory.create<NestFastifyApplication>(AppModule, new FastifyAdapter(), {
  logger: false,
  abortOnError: false,
});
await app.init();
console.log('после старта:  ', JSON.stringify(создано));

for (const user of ['аня', 'боря']) {
  const res = await app.inject({ method: 'GET', url: '/notes', headers: { 'x-user': user } });
  console.log(`GET /notes от ${user} → ${res.statusCode} ${res.body}`);
}
for (const n of [1, 2]) {
  const res = await app.inject({ method: 'GET', url: '/health' });
  console.log(`GET /health #${n} → ${res.statusCode} ${res.body}`);
}
console.log('после запросов:', JSON.stringify(создано));

const токены: Type[] = [CurrentUser, NotesService, NotesController, NotesRepo, Logger, HealthController];
for (const токен of токены) {
  try {
    console.log(`app.get(${токен.name}) → ${app.get(токен).constructor.name}`);
  } catch (err) {
    console.log(`app.get(${токен.name}) → ${(err as Error).constructor.name}`);
  }
}

const первый = await app.resolve(NotesService);
const второй = await app.resolve(NotesService);
console.log('resolve(NotesService) дважды — один объект:', первый === второй);
console.log('после resolve: ', JSON.stringify(создано));
await app.close();
nest start

Модель угадала Nest по всем пунктам. К концу старта созданы только одиночки — Metrics, NotesRepo, HealthController — и два Logger, по одному каждому потребителю. RequestLog не создан ни разу: по-запросный провайдер, которого никто не внедряет, при старте не создаётся вовсе, а вот одиночка Metrics создаётся, даже если он не нужен никому. Два запроса к /notes добавили по CurrentUser, NotesService и NotesController: контроллер маршрута собирается заново на каждый запрос. Два запроса к /health не добавили ничего — там вся цепочка из одиночек.

Дальше начинается плата. app.get(NotesService) больше не работает — InvalidClassScopeException, и то же самое на контроллере, который области не объявлял. Отказ честный: у контейнера нет заявки, а значит, нет и ответа на вопрос, какой из объектов отдавать. Вместо get остаётся resolve: он асинхронный и на каждый вызов создаёт новый экземпляр вместе со всей его по-запросной цепочкой — после двух resolve счётчик CurrentUser вырос с двух до четырёх. Это не кеш и не утечка, это новый контекст, и держать такие объекты в переменной модуля нельзя.

Отсюда список того, что ломает область запроса, если поставить её не туда. Строка const config = app.get(ConfigService) в main.ts боевого API держится ровно на том, что настройки — одиночка; стань они по-запросными, приложение упало бы на старте, не дойдя до первого запроса. Кеш или счётчик в поле сервиса теперь заводится на каждый запрос и умирает вместе с ним — то есть не работает. Подписка или таймер в конструкторе становятся подпиской на запрос. И сборка перестаёт быть бесплатной: на каждый запрос строится не один объект, а вся заражённая цепочка снизу доверху.

TRANSIENT, в отличие от REQUEST, не заразен, и прогон это показывает. NotesRepo и HealthController внедрили Logger и остались одиночками: их счётчики после четырёх запросов те же, что после старта, а app.get(HealthController) отвечает как ни в чём не бывало. Зато app.get(Logger) падает тем же InvalidClassScopeException: у самого транзитивного провайдера «одного» объекта нет, а есть по объекту на каждого потребителя — их в прогоне два.

У области запроса есть родственник для приложений, где клиентов много, а разделять их надо не по запросу, а по арендатору: @Injectable({ scope: Scope.REQUEST, durable: true }). Такой провайдер живёт не в контексте одного запроса, а в общем поддереве, которое вы назначаете сами: стратегия, поставленная один раз через ContextIdFactory.apply, смотрит на заявку и привязывает её к родительскому контексту — обычно по идентификатору арендатора. Запросов тысяча, арендаторов десять — объектов будет десять, и отдельный запрос они переживут.

Для одного приложения с одной базой это лишняя сложность, и в боевом API учебника ничего подобного нет. Но знать о такой возможности стоит: без неё «область запроса на арендатора» выглядит как выбор между утечкой и тысячей лишних объектов.

Область не объявляют — её получают. Посмотрите, кого внедряет ваш сервис, прежде чем обещать, что он одиночка.

Между «протащить пользователя параметром через все слои» и «пересобирать половину приложения на каждый запрос» есть третий путь: положить контекст рядом с цепочкой асинхронных вызовов. В Node для этого есть AsyncLocalStorage из node:async_hooks — хранилище, которое видит только та цепочка, внутри которой оно заведено, и видит даже после await.

Прогон ниже повторяет утечку из песочницы настоящим node, только рядом с полем-одиночкой стоит хранилище. Репозиторий читает обоих и фильтрует заметки дважды: один раз по полю, другой — по хранилищу. Порядок задан таймерами с разными задержками, и он не случайный: база отвечает Боре раньше, чем Ане. Прогноз: что напечатает каждая заявка и что останется после них?

прогон node 26.5.0 · 22 сентября 2026
Две заявки с перекрёстным ожиданием: поле одиночки против AsyncLocalStorage
import { AsyncLocalStorage } from 'node:async_hooks';

const ВСЕ = ['аня: купить хлеб', 'боря: пароль от почты'];

const хранилище = new AsyncLocalStorage();
let текущая = null; // «текущая заявка» полем одиночки: одно поле на весь процесс

// вместо запроса в базу: ответ приходит через заданное время
const запрос = (мс) => new Promise((готово) => setTimeout(готово, мс, ВСЕ));

// репозиторий на три уровня вглубь: кто спрашивает, ему никто не передавал
async function своиЗаметки(мс) {
  const все = await запрос(мс);
  return {
    поПолю: все.filter((строка) => строка.startsWith(`${текущая}:`)),
    изХранилища: все.filter((строка) => строка.startsWith(`${хранилище.getStore()}:`)),
  };
}

async function обработать(имя, мс) {
  текущая = имя; // так прослойка кладёт пользователя в общий контекст
  const { поПолю, изХранилища } = await своиЗаметки(мс);
  console.log(`${имя}: по полю одиночки — ${JSON.stringify(поПолю)}`);
  console.log(`${имя}: из хранилища    — ${JSON.stringify(изХранилища)}`);
}

// две заявки пришли одна за другой, а база ответила в обратном порядке
await Promise.all([
  хранилище.run('аня', обработать, 'аня', 300),
  хранилище.run('боря', обработать, 'боря', 40),
]);
console.log(`вне заявки: поле одиночки — ${текущая}, хранилище — ${хранилище.getStore()}`);
node context.mjs

Поле повторило утечку слово в слово: Аня получила заметку Бори. Хранилище отдало каждой заявке своё, хотя читает его функция, до которой от границы три вызова, и никто ей ничего не передавал: run() заводит значение на всю цепочку вызовов, включая то, что случится после await. Последняя строка — о границе: вне run() хранилище пусто, а в поле так и осталось чужое имя.

В Nest такое хранилище ставят на входе — прослойкой или перехватчиком, — а читают откуда угодно: из логгера, из репозитория, из фабрики соединений. Классы при этом остаются одиночками: ни один объект не пересобирается, а контекст всё равно свой на каждый запрос. Сквозной номер запроса в логах — ровно этот случай.

Плату стоит назвать честно. Хранилище — неявная зависимость: по подписи метода не видно, что он читает пользователя, и в юнит-тесте такой метод надо не подменить, а обернуть в run(). Забытый run() даёт undefined там, где ждали объект, и падение случается в глубине, а не на границе. И это всё-таки глобальное состояние: удержаться и класть в него только контекст запроса, а не «всё, что неудобно передавать», придётся самим.

СпособЧто пересобирается на запросЧем платите
Параметром через все слоиничегоподписи всех методов по пути; зато зависимость видна в коде
Провайдер Scope.REQUESTвся заражённая цепочка снизу вверхapp.get, кеши в полях, стоимость сборки; зато контейнер делает всё сам
AsyncLocalStorageничегонеявная зависимость и обязательный run() на входе и в тестах

Выбор из трёх строк чаще всего такой. Один-два слоя — передавайте параметром, это самый честный код. Нужен контекст на всей глубине и он именно контекст запроса — берите хранилище. Область запроса оставьте для случаев, когда объект и правда обязан быть своим у каждого запроса: открытая транзакция, соединение с правами пользователя. И держите в голове, докуда эта область поднимется. В tRPC, кстати, вопрос решён иначе: контекст там создаётся на каждый запрос и приезжает в процедуру аргументом — об этом следующая глава.

Контекст запроса — это не «данные приложения». Всё, что не умрёт вместе с ответом, в нём не место.

  • Модуль в живом проекте — это папка раздела и три списка: корень приложения не делает ничего сам, а в exports попадает то, чем уже пользуется сосед, а не то, чем он мог бы воспользоваться.
  • @Global() расширяет видимость модуля на всё приложение, но не заводит его: импортировать хотя бы раз, обычно в корне, всё равно надо, а наружу по-прежнему открыты только exports. Цена — зависимость, которой не видно в @Module.
  • Динамический модуль — статический метод, возвращающий описание. Nest различает описания по ссылке: два вызова forRoot с одинаковыми настройками дают два модуля, а значит, два пула и два счётчика.
  • Область — это ответ на вопрос «сколько объектов»: DEFAULT — один на приложение, REQUEST — один на запрос, TRANSIENT — свой каждому потребителю.
  • Область запроса заразна снизу вверх: сервис и контроллер, ничего не объявлявшие, пересобираются на каждый запрос, app.get на них отвечает InvalidClassScopeException, а resolve создаёт новый объект на каждый вызов. TRANSIENT так не делает.
  • Тот же контекст без пересборки даёт AsyncLocalStorage: run() на входе, чтение на любой глубине и после await. Платят неявной зависимостью и обязательной обёрткой в тестах.

1/4CurrentUser объявлен со Scope.REQUEST. NotesService внедряет его, NotesController внедряет сервис, и ни тот ни другой области не объявляли. Сколько объектов NotesController создаст Nest на сто запросов к его маршруту?

2/4ClockModule помечен @Global() и экспортирует Clock, но в корневом модуле его не импортировали. Что будет при старте?

3/4Два модуля вызвали SettingsModule.forRoot("postgres://app") каждый у себя, аргументы совпали дословно. Сколько объектов Settings будет в приложении?

4/4Сервис не объявлял области и внедряет только логгер со Scope.TRANSIENT. Что вернёт app.get на самом сервисе?

Уберите общее поле

Перед вами та же утечка, но лечить её будем без Nest и без хранилища: пусть имя пользователя едет параметром. Уберите класс RequestContext и поле context.user — имя должно приезжать в mine() и дальше в byOwner(). Нужен вывод «боря получил: боря: пароль от почты» и «аня получил: аня: купить хлеб»: каждому своё. И посчитайте заодно, сколько подписей пришлось поменять ради двух слоёв.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Имя становится первым параметром у обоих методов, а общее поле и класс вокруг него исчезают:

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

В заявка строка с присваиванием уходит, а вызов становится notes.mine(имя, ступенек). Вывод:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Подписей пришлось поменять две, и это всего на два слоя — в этом весь сюжет статьи. В боевом коде слоёв не два, а четыре-пять: контроллер, сервис, репозиторий, фабрика соединения, логгер. Параметр, который нужен только логгеру в самом низу, проезжает через всех, и первая же функция, куда его забыли добавить, обрывает цепочку. Из этой усталости и выросли оба способа из статьи — область запроса и хранилище на цепочку вызовов.

Два пула на ровном месте

Приложение стартует и работает, но в базе вдвое больше сессий, чем ожидали, а счётчик соединений в двух разделах показывает разное. DbModule.forRoot(url) возвращает динамический модуль с пулом. Найдите причину и предложите две починки: одну без глобального модуля, другую с ним.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Причина — два вызова forRoot. Каждый возвращает свой объект-описание, Nest считает их разными модулями, и провайдер POOL создаётся в каждом: два модуля — два пула, хотя адрес один.

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

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Починка с глобальностью — вызвать forRoot один раз в корне и пометить описание:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.
jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Тогда из NotesModule и AuthModule строка imports уходит совсем. Именно так подключена база в боевом API учебника, только пометка там стоит над классом — @Global() над DrizzleModule. И то и другое лечение оставляет один пул; выбор между ними — выбор между явным списком зависимостей и коротким модулем.

Кого пересоберёт Nest

Раздел поиска многоарендный: Tenant достаёт арендатора из заголовка, TenantCache держит найденное в Map. Ответьте по листингу, не запуская: какие классы Nest создаст к концу старта; сколько объектов TenantCache будет на сто запросов к /search; что вернёт app.get(SearchService) из main.ts; и чем в этой сборке стал кеш.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

К концу старта создан только Metrics: он одиночка, и его создают, даже если он никому не нужен. Tenant по-запросный, а TenantCache, SearchService и SearchController заражены им снизу вверх — при старте не создаётся ни один из них.

На сто запросов — сто объектов TenantCache, и столько же сервисов и контроллеров. app.get(SearchService) бросит InvalidClassScopeException: без заявки контейнеру нечего отдать, и в main.ts остаётся только асинхронный resolve, который создаст ещё один экземпляр.

Кеш стал бесполезным: Map заводится на каждый запрос и умирает с ним, так и не дождавшись второго обращения. Причём приложение работает, тесты проходят, и заметить это можно только по нагрузке на базу. Лечение — вынуть Tenant из конструктора кеша и передавать идентификатор арендатора аргументом метода: byQuery(tenantId, query). Тогда кеш снова одиночка и живёт столько же, сколько процесс, а по-запросным остаётся один Tenant — его и внедряют там, где заявка уже есть, в контроллере.