Границы: свои модули, экспорт и «провайдер не виден»

каркас≈ 15 минут

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

Контейнер из прошлой статьи умеет всё, что нужно одному классу: найти зависимости, создать их снизу вверх и раздать всем один объект. Но он плоский: любой класс получает любой токен, стоит вписать его в deps. В приложении из десятка разделов это значит, что рассылка может взять хранилище заметок в обход сервиса с его правилами, и никто этого не заметит. Nest проводит границы модулями, и одна из трёх причин его самой частой ошибки, «Nest can't resolve dependencies», — как раз граница. Добавим контейнеру модули с providers, exports и imports и разберём, почему провайдер одного модуля не виден другому без экспорта, почему imports не передаются дальше и почему самое быстрое лечение этой ошибки создаёт второй объект.

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

У файла граница есть давно: то, что модуль не экспортировал, снаружи не видно. У контейнера её нет. Возьмём заметки, которые удаляются мягко: строка остаётся в хранилище с пометкой deleted, а правило «удалённое не показываем» живёт в NotesService. В другом углу приложения — еженедельная сводка для письма. Её автору нужны тексты заметок, и он просит у контейнера то, где тексты лежат, — NotesRepo. Контейнер из прошлой статьи — в подложке. Прогноз: что окажется на экране и что уйдёт в письмо?

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

Удалённый «пароль от почты» уехал в письмо. Каждый класс опять написан честно: сервис фильтрует, сводка читает хранилище. Беда в том, что правило записано в одном месте, а обойти его можно из любого. Контейнер отдал NotesRepo, потому что его попросили, а сказать «это внутренность заметок» ему нечем: в deps годится любой класс. И когда хранилище поменяет форму строки, сломается сводка, хотя в разделе заметок о ней никто не знал.

Nest отвечает на это модулем. Модуль — класс с декоратором @Module, и границу проводят три его списка. providers — что живёт внутри. exports — что из этого видно снаружи. imports — чьи экспорты модуль согласен видеть. Это граница файла, поднятая на уровень выше: у раздела заметок NotesService — экспорт, а NotesRepo — внутренняя кухня.

Экспорт — то, на что соседям можно опираться. Всё остальное хозяин раздела меняет, никого не спрашивая.

Научим контейнер модулям. Декоратор @Module кладёт три списка в WeakMap по классу модуля, как реестр метаданных. Контейнер теперь зовётся App, и его get принимает два аргумента: из какого модуля просят и что. Метод #owner ищет модуль, которому токен принадлежит: сначала среди провайдеров того, кто просит, потом по его imports, и в импортированном модуле токен обязан быть и в providers, и в exports. Найденный модуль — владелец. Объекты кешируются по владельцу, а зависимости провайдера get просит уже от имени его модуля. Сводок две: DigestService берёт тексты у NotesService, RawDigestService — по-старому, у хранилища. Прогноз: что напечатает первая сводка и что скажет вторая попытка?

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

Первая сводка получила только живые заметки, вторая упала: «NotesRepo не виден в DigestModule». Интереснее, почему не упала первая. NotesService сам зависит от NotesRepo, которого DigestModule не видит. Но свои зависимости сервис получает в модуле, где записан: #owner вернул владельца NotesModule, и get(NotesModule, NotesRepo) нашёл хранилище среди его провайдеров. Импортирующий видит лицо модуля, а собирается это лицо внутри. Поэтому NotesModule может заменить хранилище, добавить кеш или разбить сервис на три класса, и DigestModule этого не заметит, пока NotesService отвечает по-прежнему.

Наш контейнер бросил ошибку при первом get, то есть на первом запросе к сводке. Nest проверяет то же при старте: внутри NestFactory.create он создаёт все провайдеры, и невидимая зависимость роняет запуск. Прочитать его сообщение и найти в нём модуль, где зависимости не хватило, научит статья о трёх причинах «Nest can't resolve dependencies».

Импортирующий видит лицо модуля. Всё, от чего это лицо зависит, остаётся внутри.

Сообщение «NotesRepo не виден в DigestModule» подсказывает самый короткий путь: раз не виден, вписать NotesRepo в providers модуля сводки. Ошибка уходит сразу. Ниже хранилище держит строки в памяти, заметки создаются через NotesService, а сводка считает строки в хранилище. App из прошлого примера — в подложке. Прогноз: сколько заметок насчитает сводка и что покажет сравнение в последней строке?

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

Ноль заметок и false. DigestModule получил не доступ к хранилищу заметок, а собственное хранилище: #owner нашёл NotesRepo среди его провайдеров, и объект лёг в кеш под владельцем DigestModule. Это правило из таблицы прошлой статьи: один объект на токен в модуле. Класс, записанный в providers двух модулей, даёт два объекта, как один класс под двумя токенами. С массивом в памяти ошибку выдал ноль. Хранилище поверх базы строки бы нашло, но если оно держит пул соединений или кеш, пулов станет два, а кеши разойдутся.

Лечение зависит от того, что сводке нужно на самом деле. Нужны тексты по правилам заметок — просить их у экспорта, как DigestService в прошлом примере, и при нужде дописать сервису метод. Нужно само хранилище — экспортировать его из NotesModule: это решение хозяина раздела, и оно записано в его @Module. А чужой класс в providers — повод спросить, действительно ли модулю нужен свой отдельный экземпляр.

Не виден — значит, экспортировать или импортировать. Копия в providers даёт второй объект, а не доступ к первому.

Теперь база. DatabaseModule экспортирует Db, NotesModule импортирует DatabaseModule, DigestModule импортирует NotesModule. Сводке нужны и заметки, и база — для собственного запроса. Цепочка вроде бы есть: сводка → заметки → база. Модулей сводки ниже два: DigestModule импортирует только заметки, DigestWithDbModule — ещё и базу. Прогноз: увидит ли первый Db и один ли Db достанется сводке и заметкам во втором?

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

Первый модуль Db не увидел. #owner перебирает импорты DigestModule и смотрит только их экспорты, а NotesModule экспортирует NotesService, не Db. Что NotesModule импортирует сам, соседям закрыто, так же как import внутри файла не делает чужие имена его экспортом. Второй модуль импортировал базу сам, и Db оказался тем же объектом, что у заметок: владелец у него один, DatabaseModule, сколько бы модулей его ни импортировали. Так и require выполняет файл один раз, из скольких бы мест его ни подключили.

Почему бы не передавать импорты дальше? Тогда видимое сводке зависело бы от того, что заметки импортируют сегодня. Раздел заметок перешёл на кеш и убрал DatabaseModule из своих imports — и сломалась сводка, в которой не поменяли ни строки. С нетранзитивными импортами каждый модуль перечисляет всё, чем пользуется сам, и его @Module читается как полный список зависимостей раздела. Цена — длинные imports: базу и настройки приходится импортировать почти в каждый модуль.

На этот случай в Nest есть реэкспорт: модуль кладёт в exports не провайдер, а импортированный модуль целиком, и его экспорты становятся видны тем, кто импортирует посредника. Так собирают общий CoreModule с базой и настройками, как файл-витрина собирает экспорты пакета. Передача остаётся явной: открывается только то, что посредник назвал. Научить этому App — вторая задача.

imports — кого модуль видит сам. Дальше передаётся только то, что назвали в exports.

Модель повторяет поиск Nest: сначала провайдеры модуля, затем импорты, и в импортированном модуле токен обязан быть и в providers, и в exports. Прогон ниже собирает те же разделы на Nest 12.0.3. Команда nest start из пакета @nestjs/cli компилирует проект tsc по tsconfig.json и запускает результат node. В tsconfig.json включён emitDecoratorMetadata, поэтому deps не нужен. Каждый вариант связей — свой корневой модуль и своё приложение. createApplicationContext — это NestFactory.create без HTTP: только модули и провайдеры. Вместо сообщения печатается имя исключения, а если приложение собралось — «создано». Прогноз: какие из шести корней соберутся, сколько будет хранилищ и баз там, где соберутся, и найдут ли неэкспортированное хранилище два app.get в конце?

прогон node 26.5.0 · 15 сентября 2026
Шесть корневых модулей, на каждый — своя попытка собрать приложение; в конце — app.get мимо границы
import 'reflect-metadata';
import { Injectable, Module, type Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';

@Injectable()
class Db {}

@Module({ providers: [Db], exports: [Db] })
class DatabaseModule {}

@Injectable()
class NotesRepo {
  constructor(readonly db: Db) {}
}

@Injectable()
class NotesService {
  constructor(readonly repo: NotesRepo) {}
}

@Module({ imports: [DatabaseModule], providers: [NotesRepo, NotesService], exports: [NotesService] })
class NotesModule {}

@Injectable()
class RawDigestService {
  constructor(readonly repo: NotesRepo) {}
}

@Injectable()
class DigestService {
  constructor(readonly notes: NotesService, readonly db: Db) {}
}

// каждый корень ниже — отдельный вариант связей и отдельное приложение
@Module({ imports: [NotesModule], providers: [RawDigestService] })
class RepoNotExported {}

@Module({ imports: [NotesModule, DatabaseModule], providers: [NotesRepo, RawDigestService] })
class RepoCopied {}

@Module({ imports: [NotesModule], providers: [DigestService] })
class DbThroughNotes {}

@Module({ imports: [NotesModule, DatabaseModule], providers: [DigestService] })
class DbImported {}

@Module({ imports: [DatabaseModule], providers: [NotesRepo, NotesService], exports: [NotesService, DatabaseModule] })
class NotesReexportingDb {}

@Module({ imports: [NotesReexportingDb], providers: [DigestService] })
class DbReexported {}

@Module({ imports: [DatabaseModule], providers: [NotesRepo, NotesService], exports: [NotesService, Db] })
class NotesExportingDb {}

async function start(root: Type) {
  try {
    // abortOnError: false — иначе Nest при ошибке сборки завершает процесс, а не бросает исключение
    const app = await NestFactory.createApplicationContext(root, { logger: false, abortOnError: false });
    console.log(`${root.name}: создано`);
    return app;
  } catch (err) {
    console.log(`${root.name}: ${(err as Error).constructor.name}`);
  }
}

await start(RepoNotExported);

const copied = await start(RepoCopied);
console.log('  NotesRepo один:', copied?.get(RawDigestService).repo === copied?.get(NotesService).repo);

await start(DbThroughNotes);

const imported = await start(DbImported);
console.log('  Db один:', imported?.get(DigestService).db === imported?.get(NotesService).repo.db);

const reexported = await start(DbReexported);
console.log('  Db один:', reexported?.get(DigestService).db === reexported?.get(NotesService).repo.db);

await start(NotesExportingDb);

console.log('app.get(NotesRepo):', imported?.get(NotesRepo).constructor.name);
try {
  imported?.get(NotesRepo, { strict: true });
} catch (err) {
  console.log('app.get(NotesRepo, { strict: true }):', (err as Error).constructor.name);
}
nest start

Всё, что наш контейнер делал при первом get, Nest сделал при сборке: RepoNotExported и DbThroughNotes не собрались вовсе. Копия NotesRepo в RepoCopied собралась, и хранилищ стало два. DatabaseModule, импортированный и заметками, и сводкой, дал один Db. Реэкспорт через NotesReexportingDb сработал, а NotesExportingDb, который кладёт в exports чужой Db вместо модуля, упал с UnknownExportException. Экспортировать в Nest можно свои провайдеры и импортированные модули, но не провайдеры этих модулей по одному. Экспорты Nest проверяет, пока собирает дерево модулей, ещё до первого конструктора, и называет в сообщении модуль, который экспортирует лишнее.

Свой AppNest 12
где ищется зависимостьсвои providers, затем exports импортированных модулейтак же
невидимая зависимостьошибка при первом getUnknownDependenciesException при старте
экспорт того, чего у модуля нетне проверяется, это третья задачаUnknownExportException до создания объектов
класс в providers двух модулейдва объектадва объекта
модуль в imports нескольких модулейодин набор объектоводин набор объектов
реэкспортнет, это вторая задачаexports: [DatabaseModule]; провайдер чужого модуля поштучно — нельзя
виден без importsникогдаэкспорты модуля с @Global()
просьба снаружиget(модуль, токен) по тем же границамapp.get без strict ищет во всех модулях

Последние строки прогона — о том, где граница кончается. app.get(NotesRepo) нашёл хранилище, которое не экспортировано никуда: без strict он ищет токен во всех модулях приложения, а если токен записан в двух, отдаёт последний зарегистрированный. Со strict: true он смотрит только провайдеры самого модуля, корневого или выбранного через app.select, и в импорты не заглядывает. Граница Nest — правило внедрения через конструктор, а не замок: тест или main.ts обходят её одним вызовом. Поэтому проверять видимость через app.get бессмысленно: он найдёт и то, чего не получит конструктор в чужом модуле.

Ослабить границу можно и намеренно. @Global() над модулем заставляет Nest дописать его в imports каждого модуля приложения. Видны при этом по-прежнему только его exports, а сам модуль нужно импортировать хотя бы раз, обычно в корневом: иначе Nest о нём не узнает. Так подключают настройки — ConfigModule.forRoot с isGlobal: true: модулю, который возвращает forRoot, этот флаг даёт то же, что @Global() над классом. Когда глобальный модуль уместен, а когда прячет зависимости и что такое динамический модуль вроде forRoot, разберёт статья о модулях и областях.

И последнее: граница решает, кто видит провайдер, но не то, создаётся ли он. Неэкспортированный NotesRepo Nest создаёт при старте, как любой провайдер с областью по умолчанию, и создал бы, даже не будь у него в модуле ни одного потребителя. Зато модуль, до которого нельзя дойти по imports от корня, в приложении не существует. Декоратор @Module лишь оставил сведения о классе, а читает их Nest, начиная с корня: провайдеры забытого модуля не создаются, и обработчик очереди в нём молча не работает.

  • Плоский контейнер отдаёт любой токен любому классу, и правило, записанное в сервисе, обходится просьбой к хранилищу. Модуль проводит границу, как export у файла: providers — что внутри, exports — что видно снаружи, imports — чьи экспорты модуль видит.
  • Зависимость ищется среди провайдеров своего модуля, затем в импортированных модулях, где токен должен быть и в providers, и в exports. Зависимости найденного провайдера ищутся уже в его модуле: импортирующий видит лицо модуля, а не то, из чего оно собрано.
  • Объекты кешируются по модулю-владельцу. Класс в providers двух модулей — два объекта с разным состоянием, поэтому «не виден» лечат экспортом или импортом, а не копией. Модуль, импортированный многими, даёт один набор объектов.
  • imports не транзитивны: модуль не видит того, что импортировали его импорты, иначе видимое ему зависело бы от чужих внутренностей. Передать дальше можно только явно — реэкспортом модуля в exports.
  • Nest проверяет границы при старте: невидимая зависимость — UnknownDependenciesException, экспорт того, чего у модуля нет, или провайдера чужого модуля поштучно — UnknownExportException. @Global() дописывает модуль в imports всех модулей, но видны по-прежнему только его exports.
  • Граница — правило внедрения через конструктор. app.get без strict находит токен в любом модуле, а создаёт Nest все провайдеры с областью по умолчанию в модулях, достижимых от корня, экспортированы они или нет.

1/4AppModule импортирует NotesModule, тот импортирует DatabaseModule, а DatabaseModule экспортирует Db. Провайдер из AppModule просит Db. Что будет?

2/4NotesService экспортирован, его зависимость NotesRepo — нет. DigestModule импортирует NotesModule и получает NotesService. Почему это работает?

3/4Чтобы убрать ошибку «NotesRepo не виден в DigestModule», NotesRepo вписали в providers модуля сводки. Что изменилось?

4/4DatabaseModule импортируют и NotesModule, и DigestModule. Сколько объектов Db будет в приложении?

Почините связи, не копируя провайдеры

В примере четыре раздела: настройки, база, заметки и сводка. Сейчас он падает с ошибкой «Db не виден в NotesModule», а за ней прячется вторая. Поправьте только объекты в @Module: классы сервисов не трогайте и чужие провайдеры в providers не вписывайте. Нужен вывод письмо от robot@notes.app: заметок 2 и true — у сводки и у базы одни и те же настройки. App из статьи — в подложке.

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

Первая ошибка — в DatabaseModule: Db есть в его providers, но не в exports, и NotesModule, импортировавший базу, его не видит.

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

Следом пример падает с Config не виден в DigestModule. Настройки импортирует база, но импорты дальше не передаются, и сводке их нужно импортировать самой:

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

Вывод:

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

Сравнение в последней строке ловит соблазнительную замену — providers: [DigestService, Config] в DigestModule. С ней письмо уходит так же, но печатается false: у сводки свой объект настроек. Пока настройки только читаются, разницы не видно. Появится в них изменяемое поле или кеш — и сводка разойдётся с базой.

Модуль-витрина: реэкспорт

CoreModule импортирует базу и почту и хочет открыть своим импортёрам только базу: exports: [DatabaseModule]. Сейчас #exportedBy ищет в exports только провайдеры, и NotesModule базу не видит. Допишите метод так, чтобы модуль в exports открывал свои экспорты. Нужно NotesService виден в NotesModule, Db виден в NotesModule и Mailer не виден в NotesModule: почту CoreModule импортировал для себя.

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

Если токена нет среди провайдеров, которые модуль экспортирует, нужно спросить тем же #exportedBy каждый модуль из его exports. Владельцем окажется модуль, у которого токен и в providers, и в exports.

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

Вывод:

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

Рекурсия идёт по exports, а не по imports. Обход импортов тоже нашёл бы Db, но напечатал бы Mailer виден в NotesModule: импорты стали бы транзитивными, и всё, что CoreModule подключил для себя, увидели бы его импортёры. moduleOf.has(item) отличает модуль от провайдера — провайдер в exports уже проверен строкой выше. Витрина может стоять за витриной, и владелец всё равно найдётся один, поэтому Db останется тем же объектом, через сколько бы посредников его ни получили. Nest дополнительно требует, чтобы модуль из exports был и в imports, иначе при старте UnknownExportException. А exports: [Db] в CoreModule он не примет: реэкспортировать можно только модуль целиком.

Экспорт того, чего нет

В NotesModule забыли вписать NotesService в providers, а в exports он остался. Пример падает с ошибкой «NotesService не виден в DigestModule», и она ведёт не туда: с DigestModule всё в порядке, ошибся модуль заметок. Напишите checkModules: пусть она обойдёт модули от корня по imports и бросит ошибку, если модуль экспортирует класс, которого нет ни в его providers, ни в imports. Нужно, чтобы единственной строкой стала ошибка с именем NotesModule, а после исправления модуля печаталось модули проверены и заметок за неделю: 2. App и реестр moduleOf — в подложке.

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

Функция проверяет экспорты модуля и спускается в его импорты. Множество seen не даёт проверять модуль повторно: базу и настройки импортирует половина приложения.

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

Вывод:

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

После providers: [NotesRepo, NotesService] в NotesModule:

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

Теперь ошибка называет модуль, который её допустил, и случается до первого get. imports в условии ради реэкспорта: модуль в exports законен, если импортирован. Nest проверяет то же, пока собирает дерево модулей, раньше, чем вызывает первый конструктор: это UnknownExportException, на котором в прогоне упал NotesExportingDb, и в его сообщении назван модуль.