Skip to main content

Як керувати конфігурацією в 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

Швидке налаштування

bash
npm install @nestjs/config
typescript
// 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() групує пов'язані змінні в об'єкти:

typescript
// 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 файлів

typescript
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

typescript
// Неправильно: кожен модуль мусить імпортувати ConfigModule окремо ConfigModule.forRoot() // не глобальний // Правильно: один виклик в AppModule, доступно скрізь ConfigModule.forRoot({ isGlobal: true })

Без isGlobal інжекція ConfigService у модуль, який не імпортує ConfigModule, дає помилку «provider not found». Легко пропустити при ручному bootstrap модулів у юніт тестах.

2. Немає валідації, застосунок крашиться пізніше

typescript
// Неправильно: повертає 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

typescript
// Зайве у застосунках з великим трафіком без 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

typescript
// Проблема в production: K8s підставляє реальні env vars, .env файлу немає envFilePath: '.env' // Правильно: пропускай файл у production envFilePath: process.env.NODE_ENV !== 'production' ? '.env' : undefined // або: ignoreEnvFile: true при деплої на K8s

5. Відсутній 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 БД

typescript
// .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

typescript
// 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()

typescript
// 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.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?