Ton environnement de staging fonctionne. Ton environnement de production ne fonctionne pas. Le diff entre leurs fichiers .env fait 400 lignes, et la moitié de ces lignes sont des commentaires auxquels plus personne ne fait confiance. Quelqu’un a ajouté FEATURE_X_ENABLED=true à staging le mois dernier. Personne ne l’a ajouté à production. L’application a démarré quand même, a utilisé une valeur par défaut hardcodée, et maintenant tes feature flags sont désynchronisés.
C’est l’état standard de la config cross-environment. Ce n’est pas un problème de secret management. Ce n’est pas un manque de Terraform. C’est un problème de langage. Tu exprimes une config structurée et dépendante de l’environnement dans un format qui n’a aucun concept de structure, d’environnements ou de dépendances.
Les fichiers plats clé-valeur pourrissent à l’échelle
Les variables d’environnement et les fichiers YAML plats partagent le même défaut : ils traitent la config comme un sac indifférencié de strings. Il n’y a aucune distinction entre une valeur qui devrait être identique dans tous les environnements, une valeur qui diffère intentionnellement et une valeur qui manque par accident.
Dans une configuration typique, staging.env et production.env commencent comme des copies l’une de l’autre. Avec le temps, elles divergent. Un collègue ajoute une variable à staging pour tester un feature. Un autre collègue renomme une key dans production mais pas dans staging parce que le deployment était urgent. Un troisième collègue suppose une valeur par défaut dans le code parce que la variable manque dans un fichier, et cette valeur par défaut est fausse pour l’autre environnement.
La pourriture est invisible jusqu’à ce qu’elle provoque un incident. À ce moment-là, tes fichiers .env sont devenus des artefacts write-only. La peur de casser quelque chose empêche le nettoyage. Le désordre s’accumule.
Pourquoi les bounded contexts ont besoin de leur propre langage de config
Domain-Driven Design divise les systèmes en bounded contexts, chacun avec son propre modèle et son propre langage. La config devrait suivre la même frontière. Un payment service se soucie de STRIPE_WEBHOOK_SECRET et INVOICE_GRACE_PERIOD_DAYS. Un notification service se soucie de SNS_TOPIC_ARN et RATE_LIMIT_PER_MINUTE. Leurs config shapes n’ont rien en commun, donc ils ne devraient pas partager un fichier de config.
L’insight que la plupart des équipes manquent : la config n’est pas seulement des values. C’est un schema, un ensemble de defaults et un ensemble d’overrides spécifiques à l’environnement. Quand tu traites les trois comme un seul fichier flat, tu perds la capacité de raisonner sur chacun d’eux.
Un DSL de config par bounded context rend la structure explicite. Il sépare les shared defaults des environment overlays et valide le résultat merged avant que l’application ne démarre.
Un DSL de config fonctionnel en TypeScript
Voici un petit DSL qui définit la config par context. Il utilise Zod pour la validation et des plain objects pour les environment overlays. Le pattern est le même en Python, Rust ou Go. Seule la validation library change.
D’abord, la machinerie du 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 prend un schema, un objet de config base et une map d’environment overrides. Il retourne une fonction qui prend un nom d’environnement, fait un merge de base avec l’overlay et valide le résultat. Si un required field manque ou si un type est incorrect, le parse lance une erreur avant que ton serveur ne démarre.
Maintenant un bounded context l’utilise :
// 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");
Remarque ce qui se passe ici. retryAttempts fait default à 3 partout sauf en production, où il est explicitement override à 5. logLevel est "debug" en development et "info" partout ailleurs parce que la valeur base est "info" et que seul development l’override. Il n’y a pas de duplication de shared values. Il n’y a pas besoin de deviner quel fichier a le default canonique.
Si quelqu’un oublie de définir STRIPE_SECRET_KEY en staging, l’application quitte au startup avec une erreur claire. Le failure arrive en CI, pas en production.
Comment cela arrête le config drift
Le drift arrive quand deux environnements divergent accidentellement. Le DSL l’empêche de trois façons.
Premièrement, les shared values vivent en un seul endroit : l’objet base. Si tu changes la retry policy par défaut, tu changes une ligne. Chaque environnement la reçoit automatiquement à moins qu’il n’ait un override explicite.
Deuxièmement, les environment overlays sont des objets complets, pas des diffs au niveau des lignes entre fichiers. Tu peux voir chaque valeur qui diffère de la base en regardant un seul objet. Il n’est pas nécessaire de faire un diff entre deux fichiers .env et de deviner quelles lignes comptent.
Troisièmement, le schema enforce la completeness. Si tu ajoutes un nouveau required field au schema, chaque objet d’environnement qui ne le fournit pas échouera à la validation. Tu ne peux pas ajouter un champ à production et oublier staging. Le compiler et le validator te le rappelleront.
Trade-offs : une structure explicite coûte un effort explicite
Ce pattern n’est pas gratuit. Il nécessite un upfront design. Quelqu’un doit définir le schema, décider quelles values sont base et lesquelles sont overrides, et maintenir la machinerie du DSL.
Si ton équipe est petite et ta config compte dix variables, un fichier .env suffit. L’overhead d’un schema et d’une merge layer n’en vaut pas la peine jusqu’à ce que tu ressentes la douleur du drift.
Un autre coût est la tentation de sur-abstraire. Un DSL peut devenir un config framework généraliste qui englobe chaque service de ton org. Résiste à cela. Tout le point du pattern est de garder la config locale à chaque bounded context. Si ton payment DSL et ton notification DSL sont forcés dans le même schema, tu as recréé le fichier .env monolithique avec des étapes supplémentaires.
Les secrets sont une autre considération. L’exemple lit STRIPE_SECRET_KEY depuis process.env, mais tu pourrais l’extraire d’un vault. Le DSL se fiche d’où viennent les values. Il se soucie seulement qu’elles matchent le schema avant que l’application ne les utilise.
Ce que les équipes font mal quand elles adoptent cela
L’erreur la plus commune est de traiter le schema comme de la documentation optionnelle. Les équipes définissent une interface TypeScript et ne valident jamais au runtime. Une interface est une promesse, pas un check. Si une variable d’environnement de staging manque, TypeScript ne le catchera pas parce que process.env est peuplé au runtime.
La validation doit avoir lieu au startup, avant que toute request ne soit servie. Le schema est la source of truth à la fois pour les types et les values.
La deuxième erreur est de merger les environnements dans une seule définition de config trop tôt. Commence par un bounded context. Extrais sa config dans un schema et des overlays. Laisse le pattern se prouver lui-même.
Rassembler le tout : une migration en trois étapes
Si tu pars d’un tas de fichiers .env, voici un chemin qui ne nécessite pas de big-bang rewrite.
Étape un : choisis le bounded context avec le plus d’incidents liés à la config. C’est là que la douleur est la plus forte et que l’équipe ressentira le benefit le plus rapidement.
Étape deux : fais l’inventaire de chaque config value que ce context lit. Classe-les en secrets, non-secrets spécifiques à l’environnement et shared defaults. Écris un schema Zod qui capture les shapes et constraints.
Étape trois : remplace les lectures directes de process.env dans ce context par un seul appel loadConfig() qui utilise le schema et les overlays. Déploie dans un environnement, regarde-le catcher une valeur manquante en CI, et corrige-la. La validation prouvera immédiatement sa valeur.
Répète pour le context suivant. Après trois ou quatre contexts, le pattern est auto-suffisant. Les équipes l’adoptent sans qu’on leur dise parce qu’elles l’ont vu prévenir des incidents.
FAQ
Qu’est-ce qu’un config DSL ?
Un config DSL est une domain-specific language petite pour définir la configuration dans un bounded context. Il inclut typiquement un schema, des base values et des environment overlays. C’est juste assez de structure pour rendre la config explicite et la valider avant le runtime.
Pourquoi ne pas simplement utiliser un config service centralisé ?
Un service centralisé est utile pour les valeurs dynamiques comme les feature flags. La config statique, comme les API endpoints et les retry policies, change rarement au runtime. La tirer d’un service distant ajoute une network dependency au startup. Le pattern DSL gère la config statique localement et laisse les valeurs dynamiques au service.
Comment gérer les secrets ?
Les secrets devraient entrer dans le système par des variables d’environnement ou un vault, mais ils devraient quand même passer par le schema validator. Définis-les comme des required strings dans le schema. Lis-les depuis process.env ou un client de vault à l’intérieur de l’objet d’environment overlay. Si le secret manque, la validation échoue et l’application quitte proprement.
Puis-je utiliser cela sans TypeScript ?
Oui. Le pattern fonctionne dans n’importe quel langage avec une validation library. Python a Pydantic. Rust a serde avec validator. Go a go-playground/validator. La couche DSL n’est qu’une fonction qui fait un merge d’un objet base avec un overlay et valide le résultat.
Quand est-ce que c’est overkill ?
Si ton application a moins d’une douzaine de config values et un seul environnement, un fichier .env est plus simple. Adopte le pattern DSL quand tu as plusieurs environnements, plusieurs équipes ou un historique d’incidents liés à la config.
Que faire ensuite
Audite tes fichiers .env actuels ou ton config YAML pour les values qui sont identiques dans tous les environnements. Ce sont tes base defaults. Tout ce qui diffère est un overlay. Choisis un bounded context, définis son schema et déplace ces values dans une structure qui rend les différences explicites. La première fois que ton CI catchera une variable de staging manquante avant qu’elle n’atteigne production, la frontière fonctionne.