Ваше окружение staging работает. Ваше окружение production — нет. Разница между их файлами .env составляет 400 строк, и половина из них — комментарии, которым больше никто не доверяет. Кто-то добавил FEATURE_X_ENABLED=true в staging в прошлом месяце. Никто не добавил это в production. Приложение всё равно запустилось, использовало захардкоженное значение по умолчанию, и теперь ваши feature flags рассинхронизированы.

Это стандартное состояние конфигурации между окружениями. Это не проблема управления секретами. Это не отсутствие Terraform. Это проблема языка. Вы выражаете структурированную, зависящую от окружения конфигурацию в формате, у которого нет понятия структуры, окружений или зависимостей.

Плоские файлы ключ-значение гниют в масштабе

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

В типичной настройке staging.env и production.env начинаются как копии друг друга. Со временем они расходятся. Коллега добавляет переменную в staging, чтобы протестировать функцию. Другой коллега переименовывает ключ в production, но не в staging, потому что развёртывание было срочным. Третий коллега предполагает значение по умолчанию в коде, потому что переменная отсутствует в одном из файлов, и это значение по умолчанию неверно для другого окружения.

Гниение невидимо, пока не вызовет инцидент. К тому времени ваши файлы .env стали артефактами, доступными только для записи. Страх сломать что-то мешает уборке. Беспорядок накапливается.

Почему ограниченным контекстам нужен собственный язык конфигурации

Domain-Driven Design разделяет системы на bounded contexts, каждый со своей моделью и языком. Конфигурация должна следовать той же границе. Сервис платежей заботится о STRIPE_WEBHOOK_SECRET и INVOICE_GRACE_PERIOD_DAYS. Сервис уведомлений заботится о SNS_TOPIC_ARN и RATE_LIMIT_PER_MINUTE. Их формы конфигурации не имеют ничего общего, поэтому они не должны делить файл конфигурации.

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

DSL конфигурации для каждого bounded context делает структуру явной. Он отделяет общие значения по умолчанию от наложений окружений и проверяет объединённый результат до запуска приложения.

Работающий DSL конфигурации на TypeScript

Вот небольшой DSL, который определяет конфигурацию для каждого контекста. Он использует Zod для валидации и простые объекты для наложений окружений. Паттерн тот же в Python, Rust или Go. Меняется только библиотека валидации.

Сначала механизм DSL:

import { z } from "zod";

type ConfigDef<S extends z.ZodTypeAny> = {
  schema: S;
  base: z.infer<S>;
  environments: Record<string, Partial<z.infer<S>>>;
};

function defineConfig<S extends z.ZodTypeAny>(def: ConfigDef<S>) {
  return (env: string): z.infer<S> => {
    const overlay = def.environments[env] ?? {};
    const merged = { ...def.base, ...overlay };
    return def.schema.parse(merged);
  };
}

defineConfig принимает схему, базовый объект конфигурации и карту переопределений окружений. Он возвращает функцию, которая принимает имя окружения, объединяет базу с наложением и валидирует результат. Если отсутствует обязательное поле или тип неверен, парсинг выбросит ошибку до запуска вашего сервера.

Теперь bounded context использует его:

// contexts/payment/config.ts
const loadPaymentConfig = defineConfig({
  schema: z.object({
    stripeSecretKey: z.string().startsWith("sk_"),
    webhookEndpoint: z.string().url(),
    retryAttempts: z.number().min(1).max(10),
    logLevel: z.enum(["debug", "info", "warn", "error"]),
  }),
  base: {
    retryAttempts: 3,
    logLevel: "info",
  },
  environments: {
    development: {
      stripeSecretKey: "sk_test_dummy",
      webhookEndpoint: "http://localhost:3000/webhook",
      logLevel: "debug",
    },
    staging: {
      stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
      webhookEndpoint: "https://staging.example.com/webhook",
    },
    production: {
      stripeSecretKey: process.env.STRIPE_SECRET_KEY!,
      webhookEndpoint: "https://api.example.com/webhook",
      retryAttempts: 5,
    },
  },
});

const config = loadPaymentConfig(process.env.APP_ENV ?? "development");

Обратите внимание, что происходит здесь. retryAttempts по умолчанию равен 3 везде, кроме production, где он явно переопределён на 5. logLevel равен "debug" в development и "info" везде, потому что базовое значение — "info", и только development его переопределяет. Нет дублирования общих значений. Нет необходимости угадывать, какой файл содержит каноническое значение по умолчанию.

Если кто-то забудет установить STRIPE_SECRET_KEY в staging, приложение завершится при запуске с чёткой ошибкой. Сбой произойдёт в CI, а не в production.

Как это останавливает дрейф конфигурации

Дрейф происходит, когда два окружения случайно расходятся. DSL предотвращает это тремя способами.

Во-первых, общие значения живут в одном месте: объекте base. Если вы меняете политику повторных попыток по умолчанию, вы меняете одну строку. Каждое окружение получает её автоматически, если только у него нет явного переопределения.

Во-вторых, переопределения окружений — это полные объекты, а не построчные различия между файлами. Вы можете увидеть каждое значение, отличающееся от базы, глядя на один объект. Нет необходимости сравнивать два файла .env и гадать, какие строки имеют значение.

В-третьих, схема обеспечивает полноту. Если вы добавите новое обязательное поле в схему, каждый объект окружения, который его не предоставит, не пройдёт валидацию. Вы не можете добавить поле в production и забыть про staging. Компилятор и валидатор напомнят вам.

Компромиссы: явная структура стоит явных усилий

Этот паттерн не бесплатен. Он требует предварительного проектирования. Кто-то должен определить схему, решить, какие значения являются базовыми, а какие — переопределениями, и поддерживать механизм DSL.

Если ваша команда небольшая, а конфигурация состоит из десяти переменных, файла .env достаточно. Накладные расходы на схему и слой слияния не окупаются, пока вы не почувствуете боль от дрейфа.

Ещё одна стоимость — соблазн чрезмерной абстракции. DSL может вырасти в универсальный фреймворк конфигурации, охватывающий каждый сервис в вашей организации. Сопротивляйтесь этому. Весь смысл паттерна — держать конфигурацию локальной для каждого bounded context. Если ваш payment DSL и notification DSL втиснуты в одну схему, вы воссоздали монолитный файл .env с дополнительными шагами.

Секреты — ещё один аспект. Пример читает STRIPE_SECRET_KEY из process.env, но вы могли бы получить его из хранилища секретов. DSL не заботится, откуда приходят значения. Ему важно только, чтобы они соответствовали схеме до того, как приложение их использует.

Ошибки команд при внедрении этого подхода

Самая распространённая ошибка — рассматривать схему как необязательную документацию. Команды определяют интерфейс TypeScript и никогда не валидируют во время выполнения. Интерфейс — это обещание, а не проверка. Если отсутствует переменная окружения в staging, TypeScript не поймает это, потому что process.env заполняется во время выполнения.

Валидация должна происходить при запуске, до обработки любого запроса. Схема является единственным источником правды и для типов, и для значений.

Вторая ошибка — слишком раннее слияние окружений в одно определение конфигурации. Начните с одного bounded context. Извлеките его конфигурацию в схему и наложения. Дайте паттерну доказать свою состоятельность.

Собираем всё вместе: миграция в три шага

Если вы начинаете с кучи файлов .env, вот путь, который не требует массовой перезаписи.

Шаг первый: выберите bounded context с наибольшим количеством инцидентов, связанных с конфигурацией. Там больше всего боли, и команда быстрее почувствует пользу.

Шаг второй: составьте инвентарь всех значений конфигурации, которые читает этот контекст. Разделите их на секреты, не-секреты, специфичные для окружения, и общие значения по умолчанию. Напишите схему Zod, которая захватывает формы и ограничения.

Шаг третий: замените прямые чтения process.env в этом контексте на один вызов loadConfig(), который использует схему и наложения. Разверните в одном окружении, понаблюдайте, как он ловит отсутствующее значение в CI, и исправьте его. Валидация немедленно докажет свою ценность.

Повторите для следующего контекста. После трёх-четырёх контекстов паттерн становится самоподдерживающимся. Команды внедряют его без указаний, потому что видели, как он предотвращает инциденты.

FAQ

Что такое DSL конфигурации?

DSL конфигурации — это небольшой предметно-ориентированный язык для определения конфигурации в рамках bounded context. Обычно включает схему, базовые значения и наложения окружений. Это достаточно структуры, чтобы сделать конфигурацию явной и проверить её до запуска.

Почему бы просто не использовать централизованный сервис конфигурации?

Централизованный сервис полезен для динамических значений, таких как feature flags. Статическая конфигурация, такая как API endpoints и retry policies, редко меняется во время выполнения. Извлечение её из удалённого сервиса добавляет сетевую зависимость к запуску. Паттерн DSL обрабатывает статическую конфигурацию локально и оставляет динамические значения сервису.

Как работать с секретами?

Секреты должны попадать в систему через переменные окружения или хранилище секретов, но всё равно должны проходить через валидатор схемы. Определите их как обязательные строки в схеме. Читайте их из process.env или клиента хранилища секретов внутри объекта наложения окружения. Если секрет отсутствует, валидация не проходит и приложение корректно завершается.

Можно ли использовать это без TypeScript?

Да. Паттерн работает в любом языке с библиотекой валидации. У Python есть Pydantic. У Rust есть serde с validator. У Go есть go-playground/validator. Слой DSL — это просто функция, которая объединяет базовый объект с наложением и валидирует результат.

Когда это избыточно?

Если в вашем приложении меньше дюжины значений конфигурации и одно окружение, файла .env достаточно. Внедряйте паттерн DSL, когда у вас несколько окружений, несколько команд или история инцидентов, связанных с конфигурацией.

Что делать дальше

Проведите аудит текущих файлов .env или конфигурационного YAML на предмет значений, одинаковых во всех окружениях. Это ваши базовые значения по умолчанию. Всё, что отличается, — это наложение. Выберите один bounded context, определите его схему и перенесите эти значения в структуру, которая делает различия явными. В первый раз, когда ваш CI поймает отсутствующую переменную в staging до того, как она попадёт в production, граница работает.