Як керувати конфігурацією в NestJS за допомогою configmodule?
ConfigModule завантажує .env файли в контейнер залежностей NestJS і надає їх через singleton ConfigService, який можна інжектувати будь-де в застосунку.
Теорія
TL;DR
- ConfigModule зчитує
.envпри старті застосунку, парсить пари key=value і зберігає їх уConfigService isGlobal: true- один імпорт вAppModule, доступ звідусіль без повторних імпортівconfig.get<T>('KEY', defaultValue)повертає типізоване значення з опційним fallback- Joi schema в
validationSchemaне дасть застосунку стартувати, якщо обов'язкові змінні відсутні registerAs()групує пов'язані налаштування в іменовані об'єкти на зразокdatabase.host
Швидке налаштування
npm install @nestjs/config// app.module.ts
import { ConfigModule } from '@nestjs/config';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true, // ConfigService доступний без повторних імпортів
envFilePath: '.env',
cache: true, // O(1) зчитування після першого парсингу
}),
],
})
export class AppModule {}Це весь setup. Тепер ConfigService доступний у будь-якому injectable класі застосунку.
Чим відрізняється від звичайного dotenv
Ручний dotenv записує все в глобальний process.env і дає сирі рядки без типів, без дефолтних значень, без валідації. ConfigModule використовує dotenv під капотом, але результат потрапляє в DI контейнер NestJS як singleton. Отримуєш типізовані читання, дефолти, Joi валідацію при старті і кешування. Жодного розкиданого process.env по всіх сервісах.
Коли використовувати
- Credentials БД, API ключі, номери портів: завантажуєш один раз в
AppModule, інжектуєш де потрібно - Multi-environment: передай масив у
envFilePath, NestJS вибере перший знайдений файл - Застосунки за 12-factor принципами: конфігурація живе поза кодом, змінюється без деплою
- Не підходить для констант часу компіляції (тут краще
const) або standalone Node скриптів поза NestJS
Як це працює всередині
При старті NestJS зчитує envFilePath (за замовчуванням .env), парсить кожен рядок KEY=value через dotenv і зберігає результат у внутрішній Map ConfigService. Коли викликаєш config.get<T>('KEY'), відбувається пошук по цій Map, рядок приводиться до типу T, і якщо ключ відсутній - повертається твій дефолт. З cache: true перше зчитування зберігає результат, наступні виклики пропускають пошук. Якщо передав validationSchema, схема запускається до старту застосунку. Будь-яке невалідне або відсутнє обов'язкове значення зупиняє процес з читабельним повідомленням про помилку.
Типізована конфігурація через registerAs()
Плоскі ключі на зразок DB_HOST і DB_PORT добре працюють у маленьких застосунках. Для більших registerAs() групує пов'язані змінні в об'єкти:
// config/database.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('database', () => ({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'mydb',
}));
// app.module.ts
ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] })
// any.service.ts
const host = this.configService.get<string>('database.host');
const port = this.configService.get<number>('database.port');Крапкова нотація в get() відображається на вкладені об'єкти, створені через registerAs(). Набагато чистіше ніж вручну додавати префікс до кожного плоского ключа.
Кілька .env файлів
ConfigModule.forRoot({
envFilePath: [
`.env.${process.env.NODE_ENV}.local`,
`.env.${process.env.NODE_ENV}`,
'.env.local',
'.env',
],
})NestJS обробляє масив зліва направо і зупиняється коли знаходить ключ. Раніші файли в масиві мають вищий пріоритет. Ключ у .env.development.local перемагає той самий ключ у .env. У production на Kubernetes встанови ignoreEnvFile: true і дай реальним змінним середовища з Secrets пріоритет.
Типові помилки
1. Забув isGlobal: true
// Неправильно: кожен модуль мусить імпортувати ConfigModule окремо
ConfigModule.forRoot() // не глобальний
// Правильно: один виклик в AppModule, доступно скрізь
ConfigModule.forRoot({ isGlobal: true })Без isGlobal інжекція ConfigService у модуль, який не імпортує ConfigModule, дає помилку «provider not found». Легко пропустити при ручному bootstrap модулів у юніт тестах.
2. Немає валідації, застосунок крашиться пізніше
// Неправильно: повертає NaN якщо DB_PORT='abc' в .env
const port = this.configService.get<number>('DB_PORT');
// Правильно: Joi схема зупинить застосунок на старті з зрозумілою помилкою
validationSchema: Joi.object({
DB_PORT: Joi.number().default(5432).required(),
})Пропуск валідації - найшвидший спосіб отримати інцидент о третій ночі через відсутній JWT_SECRET у новому середовищі. Joi ловить це на старті з читабельним описом, а не криптичною помилкою підключення до БД через 10 хвилин деплою.
3. config.get() в конструкторі без cache: true
// Зайве у застосунках з великим трафіком без cache: true
constructor(private config: ConfigService) {
this.dbHost = config.get('DB_HOST'); // повторне звернення до Map при кожному створенні
}
// Правильно: cache: true в forRoot()
ConfigModule.forRoot({ isGlobal: true, cache: true })У більшості застосунків це не відчутно. У мікросервісах з великим трафіком, де сервіси часто створюються заново, різниця накопичується.
4. Жорстко задано шлях .env для Docker/Kubernetes
// Проблема в production: K8s підставляє реальні env vars, .env файлу немає
envFilePath: '.env'
// Правильно: пропускай файл у production
envFilePath: process.env.NODE_ENV !== 'production' ? '.env' : undefined
// або: ignoreEnvFile: true при деплої на K8s5. Відсутній abortEarly: false в опціях Joi
За замовчуванням Joi зупиняється на першій помилці валідації. Якщо в .env відсутні п'ять змінних, фіксуватимеш їх по одній за кожен рестарт. abortEarly: false показує весь список одразу.
Де застосовується
- TypeORM:
TypeOrmModule.forRootAsync({ useFactory: (c: ConfigService) => ({ host: c.get('DB_HOST') }) }) - Prisma:
new PrismaClient({ datasources: { db: { url: config.get('DATABASE_URL') } } }) - BullMQ: Redis URL із
ConfigServiceпри налаштуванні черги - Stripe / Twilio: API ключі в
.env, ніколи не в коді, зчитуються один раз у конструкторі - Тести:
ConfigModule.forRoot({ cache: false })іprocess.env.KEYміж тестами
Питання на співбесіді
Q: Що станеться якщо обов'язкова змінна відсутня і немає валідації?
A: config.get('KEY') поверне undefined. Застосунок запуститься без помилок. Краш з'явиться пізніше, при першому запиті до БД або API, з повідомленням що не вказує на справжню причину.
Q: Як працює масив у envFilePath?
A: NestJS зчитує файли зліва направо і зупиняється коли знаходить ключ. Раніші записи в масиві мають вищий пріоритет. Ключ у .env.development.local перемагає той самий ключ у .env.
Q: Чи працює config.get('DB.HOST') з плоским .env файлом?
A: Ні. Крапкова нотація відображається на вкладені об'єкти, створені через registerAs() або кастомні load[] функції. Для плоского ключа пиши config.get('DB_HOST').
Q: Як тестувати код що залежить від ConfigService?
A: Два варіанти. Передай { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue('test-value') } } у providers тестового модуля. Або використовуй ConfigModule.forRoot({ ignoreEnvFile: true, cache: false }) і встановлюй process.env перед кожним тестом.
Q: У мікросервісах зі спільним config repo, як перевизначати конфіг окремо для сервісу без повного деплою?
A: Встанови ignoreEnvFile: true і використовуй Kubernetes ConfigMaps як реальні змінні середовища. Для специфічних налаштувань сервісу передай кастомну фабрику в load: [configurationFactory] що зчитує YAML або звертається до Vault/AWS SSM. Рестарт поду - і нова конфігурація активна.
Приклади
Базовий: зчитування credentials БД
// .env
// DB_HOST=localhost
// DB_PORT=5432
// DB_NAME=myapp
// database.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class DatabaseService {
constructor(private config: ConfigService) {}
getConnectionString(): string {
const host = this.config.get<string>('DB_HOST', 'localhost'); // fallback якщо відсутній
const port = this.config.get<number>('DB_PORT', 5432);
const name = this.config.get<string>('DB_NAME');
return `postgres://${host}:${port}/${name}`;
// Результат: "postgres://localhost:5432/myapp"
}
}get<T>() приймає параметр типу і дефолтне значення. Якщо DB_PORT відсутній у .env, метод поверне 5432, а не undefined.
Середній рівень: Joi валідація + інтеграція з TypeORM
// app.module.ts
import * as Joi from 'joi';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
validationSchema: Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
DB_HOST: Joi.string().required(),
DB_PORT: Joi.number().default(5432),
DB_USER: Joi.string().required(),
DB_PASS: Joi.string().required(),
DB_NAME: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
}),
validationOptions: {
abortEarly: false, // показати всі помилки, не тільки першу
},
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
type: 'postgres',
host: config.get('DB_HOST'),
port: config.get<number>('DB_PORT'),
username: config.get('DB_USER'),
password: config.get('DB_PASS'),
database: config.get('DB_NAME'),
autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production',
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}Якщо JWT_SECRET коротший за 32 символи або DB_HOST відсутній, застосунок виводить повний список помилок валідації і завершує роботу до першого запиту.
Розширений рівень: іменована конфігурація через registerAs()
// config/app.config.ts
import { registerAs } from '@nestjs/config';
export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10),
env: process.env.NODE_ENV || 'development',
jwtSecret: process.env.JWT_SECRET,
stripeKey: process.env.STRIPE_SECRET_KEY,
}));
// config/database.config.ts
export default registerAs('database', () => ({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432', 10),
name: process.env.DB_NAME || 'mydb',
}));
// app.module.ts
ConfigModule.forRoot({
isGlobal: true,
load: [appConfig, databaseConfig], // обидва зливаються в ConfigService
})
// payment.service.ts
@Injectable()
export class PaymentService {
private stripeKey: string;
constructor(private config: ConfigService) {
// доступ через крапкову нотацію до іменованого об'єкта 'app'
this.stripeKey = this.config.get<string>('app.stripeKey')!;
}
charge(amount: number) {
console.log(`Charge with key: ${this.stripeKey.slice(0, 4)}****`);
// Результат: "Charge with key: sk_l****"
}
}registerAs() розділяє зони відповідальності. Auth сервіс читає з app.*, сервіс БД - з database.*. Ніхто не знає про конфіг сусіда і ніхто не чіпає голий process.env.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.