Config-модуль: типизированный, валидируемый env
ConfigModule.forRoot оборачивает dotenv, валидирует env на старте по схеме (fail fast) и отдаёт типизированный ConfigService. Используй registerAs для namespace-конфига и инжекть его через ConfigType — не размазывай process.env.
Сервис неделю зелёный в CI и на staging. Ты выкатываешь в прод, и через 200 мс после старта первый запрос на оплату взрывается: getaddrinfo ENOTFOUND undefined. Причина — одна строка в клиенте платежей — new Stripe(process.env.STRIPE_SECRET_KEY) — и в проде эта переменная просто не была задана, так что всё это время она была undefined. На старте ничего не упало, потому что никто не проверял. Процесс спокойно принимал трафик с пустой конфигурацией и взорвался только тогда, когда реальный клиент попал на единственный путь кода, использующий недостающее значение. Фикс — не «не забудь задать переменную», а заставить процесс отказываться стартовать, когда его конфигурация неверна.
ConfigModule оборачивает dotenv и централизует чтения
@nestjs/config — тонкий, мнениевый слой поверх dotenv (библиотека, которая читает .env-файл и подкладывает переменные в process.env). Ты регистрируешь его один раз через ConfigModule.forRoot(); он читает .env (и .env.${NODE_ENV}, если указать на него), подмешивает process.env, опционально валидирует результат и отдаёт всё через инжектируемый ConfigService. Пометка isGlobal: true означает, что любой feature-модуль может инжектить ConfigService без повторного импорта ConfigModule, а cache: true мемоизирует чтения, чтобы config.get() не перечитывал process.env при каждом вызове.
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // инжектить ConfigService где угодно, без реимпорта
cache: true, // мемоизировать чтения process.env
envFilePath: `.env.${process.env.NODE_ENV}`, // файл на окружение
expandVariables: true, // интерполяция ${HOST}:${PORT} в .env
ignoreEnvFile: process.env.NODE_ENV === 'production', // прод: только реальный env, без файла
}),
],
})
export class AppModule {}Дальше ты читаешь значения типизированным геттером — config.get<string>('DATABASE_URL') — вместо обращения к process.env напрямую. Выигрыш не в синтаксисе; он в том, что ConfigService — настоящий provider. Он инжектируемый, поэтому участвует в DI; он мокается, поэтому тесты могут передать фейковую конфигурацию вместо мутации глобального process.env; и поскольку чтения централизованы, валидация происходит ровно в одном месте.
В проде ставь ignoreEnvFile: true. Закоммиченный .env никогда не должен быть источником истины в проде — значения приходят из реального окружения (оркестратора, secrets-менеджера, платформы), а чтение устаревшего файла — это как раз способ протащить staging-креды в прод-деплой.
Сеньорская мысль: валидируй на старте, fail fast
Спроси себя: когда должен упасть неправильно сконфигурированный деплой — на старте, где инженер увидит это в логе деплоя, или через три часа, когда клиент попадёт на сломанный путь кода? Ответ определяет всё, что идёт дальше.
Определяющая фича ConfigModule — не загрузка, а валидация. Передай validationSchema (Joi — библиотека декларативной валидации объектов), и Nest валидирует слитое окружение один раз, на init модуля. Если обязательная переменная отсутствует или сломана, процесс выбрасывает на bootstrap с точным сообщением и вообще не стартует. Это превращает скрытый undefined времени запроса в громкий, немедленный сбой времени деплоя.
import * as Joi from 'joi';
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'test', 'production').default('development'),
PORT: Joi.number().port().default(3000),
DATABASE_URL: Joi.string().uri().required(), // нет -> старт падает
STRIPE_SECRET_KEY: Joi.string().required(), // баг из хука, пойманный на старте
}),
validationOptions: { abortEarly: false }, // сообщить про ВСЕ плохие переменные, не только первую
});Предпочитаешь Zod или кастомную validate-функцию, если не хочешь тащить Joi — Nest принимает любую validate: (config) => validatedConfig, которая выбрасывает при провале. Она выполняется в той же точке lifecycle и даёт ту же гарантию fail-fast:
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(3000),
DATABASE_URL: z.string().url(), // выбросит на старте, если нет / сломано
});
ConfigModule.forRoot({
isGlobal: true,
validate: (raw) => envSchema.parse(raw), // ZodError -> процесс отказывается стартовать
});В любом случае контракт один: сломанный DATABASE_URL роняет старт с понятным сообщением, а не молча остаётся undefined до первого запроса в проде.
Namespace-конфиг через registerAs и типизированная инъекция
Плоский мешок строк config.get('DATABASE_HOST') не масштабируется — ключи stringly-typed, а группировка неявная. registerAs('database', factory) определяет namespace: именованный объект конфигурации, собранный из process.env, зарегистрированный через forFeature или массив load и — главное — инжектируемый как строго типизированная единица.
// database.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
host: process.env.DATABASE_HOST ?? 'localhost',
port: parseInt(process.env.DATABASE_PORT ?? '5432', 10),
url: process.env.DATABASE_URL,
}));// любой provider
import { Inject, Injectable } from '@nestjs/common';
import { ConfigType } from '@nestjs/config';
import databaseConfig from './database.config';
@Injectable()
export class DbService {
constructor(
@Inject(databaseConfig.KEY)
private readonly dbConfig: ConfigType<typeof databaseConfig>,
) {}
connect() {
// dbConfig.port типизирован как `number`, dbConfig.host — `string` — без кастов, автокомплит работает
return `${this.dbConfig.host}:${this.dbConfig.port}`;
}
}ConfigType<typeof databaseConfig> выводит статический тип прямо из возвращаемого значения фабрики, а databaseConfig.KEY — это DI-токен для этого namespace. В итоге конфиг типизирован (компилятор знает, что port — number), валидирован (namespace собран из env, прошедшего схему) и тестируем (инжектишь заглушку для databaseConfig.KEY в юнит-тесте). Сравни с process.env.DATABASE_PORT, размазанным по кодовой базе: нетипизировано (всегда string | undefined), невалидировано, нетестируемо и невидимо для DI.
process.env vs ConfigService vs самописный синглтон
Почему просто не читать process.env или не собрать один объект config.ts самому? Таблица делает разрыв конкретным:
| Свойство | Сырой process.env | Самописный синглтон | ConfigService |
|---|---|---|---|
| Типы | Всегда string | undefined | Только если ты их напишешь | Типизировано через ConfigType / get<T> |
| Валидация | Нет — падает при первом использовании | Сам, легко забыть | Схема на старте — fail fast |
| DI / инжектируемость | Нет — глобальный доступ | Нет — импортируемый глобал | Да — настоящий provider |
| Мокается в тестах | Мутировать глобальный env (течёт) | Тяжело — состояние уровня модуля | Да — подсунуть фейк |
Самописный синглтон даёт типы и одно место чтения, но это состояние уровня модуля, которое нельзя инжектить или чисто мокать, а валидация — это та дисциплина, которую ты не забыл написать. ConfigService даёт все три — типы, валидацию на старте и DI — бесплатно.
▸Почему это работает
Почему валидация принадлежит старту, а не первому чтению? Проверка времени запроса срабатывает только на пути кода, который трогает переменную, — на единственном маршруте, вызывающем Stripe, на единственной задаче, открывающей БД. Пока этот путь не выполнится в проде, недостающая переменная невидима, поэтому сбой всплывает спустя часы после деплоя, на клиенте, далеко от деплоя, который его вызвал. Валидация на старте схлопывает эту дистанцию: процесс либо стартует с полной, корректной конфигурацией, либо не стартует вовсе — а это ровно тот сигнал, на который умеет реагировать деплой-пайплайн (новые поды не становятся healthy, выкат останавливается).
Ты поднимаешь конфиг для нового Nest-сервиса. DATABASE_URL и STRIPE_SECRET_KEY обязательны, и тебе нужна типобезопасность плюс гарантия, что неправильно сконфигурированный деплой никогда не дойдёт до клиентов. К чему ты тянешься?
Обязательный DATABASE_URL отсутствует в проде. Что произойдёт с настроенной validationSchema и без неё?
Тебе нужен типизированный объект конфига `database` (host: string, port: number), инжектируемый в provider-ы. Какой подход даёт типы плюс DI?
- 01Что на самом деле делает ConfigModule.forRoot и что тебе дают isGlobal, cache и ignoreEnvFile по отдельности?
- 02Объясни «валидируй на старте, fail fast» и как это подключается через Joi и кастомную функцию validate.
- 03Почему registerAs + ConfigType лучше, чем размазывать process.env, и как инжектить namespace?
Config-модуль — это то, как Nest-сервис перестаёт относиться к переменным окружения как к окружающим глобальным строкам и начинает относиться к ним как к типизированному, валидированному, инжектируемому контракту. ConfigModule.forRoot — тонкий слой поверх dotenv: он загружает .env (и .env.${NODE_ENV}), сливает process.env, опционально валидирует и отдаёт инжектируемый ConfigService, который ты читаешь через config.get<T>(); isGlobal позволяет любому модулю его инжектить, cache мемоизирует чтения, а ignoreEnvFile: true в проде держит источник истины в реальном окружении, а не в закоммиченном файле. Сеньорская мысль — fail-fast валидация: validationSchema (Joi) или кастомная функция validate (Zod) выполняется один раз на старте, так что недостающий или сломанный DATABASE_URL роняет старт с понятным сообщением — новый деплой никогда не становится healthy — вместо того чтобы скрытно быть undefined до первого запроса, который трогает его в проде. Для структуры registerAs(‘database’, factory) определяет namespace, инжектируемый через @Inject(databaseConfig.KEY), типизированный как ConfigType<typeof databaseConfig>, что даёт конфиг типизированный, валидированный и мокаемый — всё то, чем размазанные, stringly-typed, невалидированные, неинжектируемые чтения process.env не являются. Выбирай ConfigService с валидацией на старте вместо сырого process.env или самописного синглтона. Теперь, когда встретишь сервис, чей конструктор тянется к process.env напрямую, ты знаешь, что поставить вместо него — и почему деплой с этой заменой будет безопаснее того, что она вытеснит.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.