Your staging environment works. Your production environment does not. The diff between their .env files is 400 lines, and half of those lines are comments that nobody trusts anymore. Someone added FEATURE_X_ENABLED=true to staging last month. Nobody added it to production. The application started anyway, used a hardcoded default, and now your feature flags are out of sync.

This is the standard state of cross-environment config. It is not a secret management problem. It is not a lack of Terraform. It is a language problem. You are expressing structured, environment-dependent config in a format that has no concept of structure, environments, or dependencies.

Flat key-value files rot at scale

Environment variables and flat YAML files share the same flaw: they treat config as an undifferentiated bag of strings. There is no distinction between a value that should be identical across environments, a value that differs intentionally, and a value that is missing by accident.

In a typical setup, staging.env and production.env start as copies of each other. Over time they diverge. A teammate adds a variable to staging to test a feature. Another teammate renames a key in production but not in staging because the deployment was urgent. A third teammate assumes a default in code because the variable is missing from one file, and that default is wrong for the other environment.

The rot is invisible until it causes an incident. By then, your .env files have become write-only artifacts. Fear of breaking something prevents cleanup. The mess compounds.

Why bounded contexts need their own config language

Domain-Driven Design splits systems into bounded contexts, each with its own model and language. Config should follow the same boundary. A payment service cares about STRIPE_WEBHOOK_SECRET and INVOICE_GRACE_PERIOD_DAYS. A notification service cares about SNS_TOPIC_ARN and RATE_LIMIT_PER_MINUTE. Their config shapes have nothing in common, so they should not share a config file.

The insight most teams miss: config is not just values. It is a schema, a set of defaults, and a set of environment-specific overrides. When you treat all three as a single flat file, you lose the ability to reason about any of them.

A config DSL per bounded context makes the structure explicit. It separates shared defaults from environment overlays and validates the merged result before the application starts.

A working config DSL in TypeScript

Here is a small DSL that defines config per context. It uses Zod for validation and plain objects for environment overlays. The pattern is the same in Python, Rust, or Go. Only the validation library changes.

First, the DSL machinery:

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 takes a schema, a base config object, and a map of environment overrides. It returns a function that takes an environment name, merges base with the overlay, and validates the result. If a required field is missing or a type is wrong, the parse throws before your server starts.

Now a bounded context uses it:

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

Notice what happens here. retryAttempts defaults to 3 everywhere except production, where it is explicitly overridden to 5. logLevel is "debug" in development and "info" everywhere else because the base value is "info" and only development overrides it. There is no duplication of shared values. There is no guessing which file has the canonical default.

If someone forgets to set STRIPE_SECRET_KEY in staging, the application exits on startup with a clear error. The failure happens in CI, not in production.

How this stops config drift

Drift happens when two environments diverge accidentally. The DSL prevents this in three ways.

First, shared values live in one place: the base object. If you change the default retry policy, you change one line. Every environment gets it automatically unless it has an explicit override.

Second, environment overrides are complete objects, not line-level diffs between files. You can see every value that differs from the base by looking at one object. There is no need to diff two .env files and guess which lines matter.

Third, the schema enforces completeness. If you add a new required field to the schema, every environment object that does not provide it will fail validation. You cannot add a field to production and forget staging. The compiler and the validator will remind you.

Trade-offs: explicit structure costs explicit effort

This pattern is not free. It requires upfront design. Someone has to define the schema, decide which values are base and which are overrides, and keep the DSL machinery maintained.

If your team is small and your config is ten variables, a .env file is fine. The overhead of a schema and merge layer is not worth it until you feel the pain of drift.

Another cost is the temptation to over-abstract. A DSL can grow into a general-purpose config framework that spans every service in your org. Resist this. The whole point of the pattern is to keep config local to each bounded context. If your payment DSL and your notification DSL are forced into the same schema, you have recreated the monolithic .env file with extra steps.

Secrets are another consideration. The example reads STRIPE_SECRET_KEY from process.env, but you could pull it from a vault. The DSL does not care where values come from. It only cares that they match the schema before the application uses them.

What teams get wrong when they adopt this

The most common mistake is treating the schema as optional documentation. Teams define a TypeScript interface and never validate at runtime. An interface is a promise, not a check. If a staging environment variable is missing, TypeScript will not catch it because process.env is populated at runtime.

Validation must happen at startup, before any request is served. The schema is the source of truth for both types and values.

The second mistake is merging environments into a single config definition too early. Start with one bounded context. Extract its config into a schema and overlays. Let the pattern prove itself.

Putting it together: a three-step migration

If you are starting from a pile of .env files, here is a path that does not require a big-bang rewrite.

Step one: pick the bounded context with the most config-related incidents. That is where the pain is highest and the team will feel the benefit fastest.

Step two: inventory every config value that context reads. Sort them into secrets, environment-specific non-secrets, and shared defaults. Write a Zod schema that captures the shapes and constraints.

Step three: replace direct process.env reads in that context with a single loadConfig() call that uses the schema and overlays. Deploy to one environment, watch it catch a missing value in CI, and fix it. The validation will immediately prove its value.

Repeat for the next context. After three or four contexts, the pattern is self-sustaining. Teams adopt it without being told because they have seen it prevent incidents.

FAQ

What is a config DSL?

A config DSL is a small, domain-specific language for defining configuration within a bounded context. It typically includes a schema, base values, and environment overlays. It is just enough structure to make config explicit and validate it before runtime.

Why not just use a centralized config service?

A centralized service is useful for dynamic values like feature flags. Static config, like API endpoints and retry policies, rarely changes at runtime. Pulling it from a remote service adds a network dependency to startup. The DSL pattern handles static config locally and leaves dynamic values to the service.

How do I handle secrets?

Secrets should enter the system through environment variables or a vault, but they should still pass through the schema validator. Define them as required strings in the schema. Read them from process.env or a vault client inside the environment overlay object. If the secret is missing, validation fails and the application exits cleanly.

Can I use this without TypeScript?

Yes. The pattern works in any language with a validation library. Python has Pydantic. Rust has serde with validator. Go has go-playground/validator. The DSL layer is just a function that merges a base object with an overlay and validates the result.

When is this overkill?

If your application has fewer than a dozen config values and one environment, a .env file is simpler. Adopt the DSL pattern when you have multiple environments, multiple teams, or a history of config-related incidents.

What to do next

Audit your current .env files or config YAML for values that are identical across environments. Those are your base defaults. Anything that differs is an overlay. Pick one bounded context, define its schema, and move those values into a structure that makes the differences explicit. The first time your CI catches a missing staging variable before it reaches production, the boundary is working.