Tu entorno de staging funciona. Tu entorno de production no. El diff entre sus archivos .env es de 400 líneas, y la mitad son comentarios en los que ya nadie confía. Alguien añadió FEATURE_X_ENABLED=true a staging el mes pasado. Nadie lo añadió a production. La aplicación arrancó de todos modos, usó un default hardcoded, y ahora tus feature flags están desincronizados.

Este es el estado estándar de la config cross-environment. No es un problema de secret management. No es falta de Terraform. Es un problema de lenguaje. Estás expresando config estructurada y dependiente del entorno en un formato que no tiene concepto de estructura, entornos ni dependencias.

Los archivos planos clave-valor se pudren a escala

Las variables de entorno y los archivos YAML planos comparten el mismo defecto: tratan la config como una bolsa indiferenciada de strings. No hay distinción entre un valor que debería ser idéntico en todos los entornos, un valor que difiere intencionalmente y un valor que falta por accidente.

En una configuración típica, staging.env y production.env empiezan como copias mutuas. Con el tiempo divergen. Un compañero añade una variable a staging para probar un feature. Otro compañero renombra una key en production pero no en staging porque el deployment era urgente. Un tercer compañero asume un default en el código porque la variable falta en un archivo, y ese default es erróneo para el otro entorno.

La pudrición es invisible hasta que causa un incident. Para entonces, tus archivos .env se han convertido en artefactos write-only. El miedo a romper algo impide la limpieza. El desorden se acumula.

Por qué los bounded contexts necesitan su propio lenguaje de config

Domain-Driven Design divide los sistemas en bounded contexts, cada uno con su propio modelo y lenguaje. La config debería seguir el mismo límite. Un payment service se preocupa por STRIPE_WEBHOOK_SECRET e INVOICE_GRACE_PERIOD_DAYS. Un notification service se preocupa por SNS_TOPIC_ARN y RATE_LIMIT_PER_MINUTE. Sus config shapes no tienen nada en común, así que no deberían compartir un archivo de config.

La intuición que la mayoría de los equipos pasa por alto: la config no son solo values. Es un schema, un conjunto de defaults y un conjunto de overrides específicos del entorno. Cuando tratas los tres como un único archivo flat, pierdes la capacidad de razonar sobre cualquiera de ellos.

Un DSL de config por bounded context hace la estructura explícita. Separa los shared defaults de los environment overlays y valida el resultado merged antes de que la aplicación arranque.

Un DSL de config funcional en TypeScript

Aquí hay un pequeño DSL que define config por context. Usa Zod para validation y plain objects para los environment overlays. El pattern es el mismo en Python, Rust o Go. Solo cambia la validation library.

Primero, la maquinaria del 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 toma un schema, un objeto de config base y un mapa de environment overrides. Devuelve una función que toma un nombre de entorno, hace merge de base con el overlay y valida el resultado. Si falta un required field o un type es incorrecto, el parse lanza un error antes de que tu servidor arranque.

Ahora un bounded context lo usa:

// 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");

Fíjate en lo que ocurre aquí. retryAttempts hace default a 3 en todas partes excepto production, donde se override explícitamente a 5. logLevel es "debug" en development e "info" en todas partes porque el valor base es "info" y solo development lo overridet. No hay duplicación de shared values. No hay que adivinar qué archivo tiene el default canónico.

Si alguien olvida establecer STRIPE_SECRET_KEY en staging, la aplicación sale en el startup con un error claro. El failure ocurre en CI, no en production.

Cómo esto detiene el config drift

El drift ocurre cuando dos entornos divergen accidentalmente. El DSL lo previene de tres maneras.

Primero, los shared values viven en un solo lugar: el objeto base. Si cambias la retry policy por default, cambias una línea. Cada entorno la recibe automáticamente a menos que tenga un override explícito.

Segundo, los environment overrides son objetos completos, no diffs a nivel de línea entre archivos. Puedes ver cada valor que difiere del base mirando un solo objeto. No hay necesidad de hacer diff entre dos archivos .env y adivinar qué líneas importan.

Tercero, el schema fuerza la completeness. Si añades un nuevo required field al schema, cada objeto de entorno que no lo proporcione fallará la validation. No puedes añadir un campo a production y olvidar staging. El compiler y el validator te lo recordarán.

Trade-offs: la estructura explícita cuesta esfuerzo explícito

Este pattern no es gratis. Requiere upfront design. Alguien tiene que definir el schema, decidir qué values son base y cuáles son overrides, y mantener la maquinaria del DSL.

Si tu equipo es pequeño y tu config tiene diez variables, un archivo .env está bien. El overhead de un schema y una merge layer no vale la pena hasta que sientas el dolor del drift.

Otro coste es la tentación de sobre-abstractar. Un DSL puede crecer hasta convertirse en un config framework de propósito general que abarque cada servicio de tu org. Resiste eso. Todo el punto del pattern es mantener la config local a cada bounded context. Si tu payment DSL y tu notification DSL se fuerzan al mismo schema, has recreado el archivo .env monolítico con pasos extra.

Los secrets son otra consideración. El ejemplo lee STRIPE_SECRET_KEY de process.env, pero podrías sacarlo de un vault. El DSL no se importa de dónde vienen los values. Solo se importa que hagan match con el schema antes de que la aplicación los use.

Lo que los equipos hacen mal cuando adoptan esto

El error más común es tratar el schema como documentación opcional. Los equipos definen una interface de TypeScript y nunca validan en runtime. Una interface es una promesa, no un check. Si falta una variable de entorno de staging, TypeScript no lo cachará porque process.env es population en runtime.

La validation debe ocurrir en el startup, antes de que se sirva cualquier request. El schema es la source of truth tanto para types como para values.

El segundo error es mergear entornos en una única definición de config demasiado pronto. Empieza con un bounded context. Extrae su config en un schema y overlays. Deja que el pattern se pruebe a sí mismo.

Poniéndolo todo junto: una migration en tres pasos

Si partes de un montón de archivos .env, aquí hay un camino que no requiere un big-bang rewrite.

Paso uno: elige el bounded context con más incidents relacionados con config. Ahí es donde el dolor es mayor y el equipo sentirá el benefit más rápido.

Paso dos: inventaria cada config value que ese context lee. Clasifícalos en secrets, non-secrets específicos del entorno y shared defaults. Escribe un schema de Zod que capture los shapes y constraints.

Paso tres: reemplaza las lecturas directas de process.env en ese context por una sola llamada a loadConfig() que use el schema y los overlays. Deploya a un entorno, míralo cachar un valor faltante en CI, y arréglalo. La validation demostrará inmediatamente su valor.

Repite para el siguiente context. Después de tres o cuatro contexts, el pattern es autosostenible. Los equipos lo adoptan sin que se les diga porque lo han visto prevenir incidents.

FAQ

¿Qué es un config DSL?

Un config DSL es una domain-specific language pequeña para definir configuración dentro de un bounded context. Típicamente incluye un schema, base values y environment overlays. Es justo la estructura suficiente para hacer la config explícita y validarla antes del runtime.

¿Por qué no usar un config service centralizado?

Un servicio centralizado es útil para valores dinámicos como feature flags. La config estática, como API endpoints y retry policies, raramente cambia en runtime. Sacarla de un servicio remoto añade una network dependency al startup. El pattern DSL maneja la config estática localmente y deja los valores dinámicos al servicio.

¿Cómo manejo los secrets?

Los secrets deberían entrar al sistema a través de variables de entorno o un vault, pero aún deberían pasar por el schema validator. Defínelos como required strings en el schema. Léelos de process.env o un cliente de vault dentro del objeto de environment overlay. Si falta el secret, la validation falla y la aplicación sale limpamente.

¿Puedo usar esto sin TypeScript?

Sí. El pattern funciona en cualquier lenguaje con una validation library. Python tiene Pydantic. Rust tiene serde con validator. Go tiene go-playground/validator. La capa DSL es solo una función que hace merge de un objeto base con un overlay y valida el resultado.

¿Cuándo es esto overkill?

Si tu aplicación tiene menos de una docena de config values y un entorno, un archivo .env es más simple. Adopta el pattern DSL cuando tengas múltiples entornos, múltiples equipos o un historial de incidents relacionados con config.

Qué hacer a continuación

Audita tus archivos .env actuales o config YAML en busca de values que sean idénticos en todos los entornos. Esos son tus base defaults. Cualquier cosa que difiera es un overlay. Elige un bounded context, define su schema y mueve esos values a una estructura que haga las diferencias explícitas. La primera vez que tu CI cacha una variable de staging faltante antes de que llegue a production, el límite está funcionando.