Deine Anwendung wirft einen TypeError drei Stunden am Freitagabend. Der Stack Trace zeigt auf eine tief verschachtelte Eigenschaft eines Config-Objekts. Der Wert ist undefined. Jemand hat eine .env-Änderung in die Produktion gepusht, ohne die Validierungslogik zu aktualisieren.

Das ist kein Runtime-Bug. Es ist ein Policy-Failure. Du hast nicht vertrauenswürdige Daten in dein System gelassen, ohne sie zuvor zu prüfen.

Warum Runtime-Config-Validierung zu spät ist

Die meisten Teams validieren Config reaktiv. Eine fehlende Umgebungsvariable verursacht einen Crash. Ein ungültiger URL-String propagiert sich, bis fetch() wirft. Eine falsch benannte Key in einer JSON-Config-Datei liegt brach, bis das Feature, das sie liest, endlich aktiviert wird – Tage oder Wochen später.

Das Problem verschlimmert sich, wenn du domänenspezifische Sprachen innerhalb von Bounded Contexts einführst. Jeder Context hat seine eigene Config-Form. Ein Payment-Service interessiert sich für STRIPE_WEBHOOK_SECRET. Ein Notification-Service interessiert sich für SNS_TOPIC_ARN. Die Formen unterscheiden sich, aber der Failure Mode ist derselbe: Ungültige Config gelangt ins System, dann scheitert das System, wenn die ungültigen Daten endlich verwendet werden.

TypeScript rettet dich hier nicht. process.env gibt string | undefined zurück. Dein Config-Interface verspricht einen String, aber das Interface lügt, bis etwas es durchsetzt.

Die Lösung ist, Config an der Boundary zu validieren, bevor sie die Runtime erreicht.

Schema-Validierung an der Systemboundary

Behandle Config als untrusted Input, denn das ist sie. Umgebungsvariablen, JSON-Dateien und Remote-Config-Services sollten alle durch einen Schema-Validator laufen, bevor deine Anwendung sie verwendet.

Das ist die Form, die funktioniert:

  1. Definiere ein Schema, das den Erwartungen deiner Domain entspricht.
  2. Parse die rohe Config durch dieses Schema.
  3. Verwende das geparste, getypte Resultat überall sonst.

Wenn das Parsing fehlschlägt, beendet sich die Anwendung sofort mit einem klaren Fehler. Kein partieller Startup. Kein Config-Objekt mit fehlenden Feldern, das in den Rest deines Codes driftet.

Hier ist ein konkretes Beispiel mit Zod:

import { z } from "zod";

const PaymentConfig = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string().min(20),
  MAX_RETRY_ATTEMPTS: z
    .string()
    .transform((s) => parseInt(s, 10))
    .pipe(z.number().min(1).max(10)),
});

const raw = {
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
  STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
  MAX_RETRY_ATTEMPTS: process.env.MAX_RETRY_ATTEMPTS ?? "3",
};

const config = PaymentConfig.parse(raw);

Wenn STRIPE_SECRET_KEY fehlt, wirft die Anwendung, bevor sie irgendwelche Routes mounted. Wenn MAX_RETRY_ATTEMPTS "fifteen" ist, schlägt das Parse mit einem klaren Fehler fehl. Der Typ von config nach dem Parsing ist genau das, was PaymentConfig beschreibt, nicht string | undefined.

Pro-Context-Schemas, nicht ein globales Objekt

Wenn du Config pro Bounded Context modellierst, vermeidest du die Versuchung, jede Umgebungsvariable in ein einziges Config-Objekt zu kippen, das jedes Modul importiert. Dieses globale Objekt erzeugt hidden coupling. Eine Änderung an der Config des Notification-Services erfordert, eine Datei zu berühren, die auch der Payment-Service importiert.

Stattdessen definiert jeder Bounded Context sein eigenes Schema:

// contexts/payment/config.ts
export const PaymentConfig = z.object({
  STRIPE_SECRET_KEY: z.string(),
  WEBHOOK_ENDPOINT: z.string().url(),
});

// contexts/notification/config.ts
export const NotificationConfig = z.object({
  SNS_TOPIC_ARN: z.string().startsWith("arn:aws:sns:"),
  RATE_LIMIT_PER_MINUTE: z.number().default(100),
});

Jeder Context parst nur das, was er braucht. Der Payment-Service sieht keine Notification-Variablen, und vice versa. Das spiegelt die Domain-Boundaries wider, die du bereits in deinem Code gezogen hast.

Build-Time-Validierung mit generierten Typen

Parsing beim Startup fängt die meisten Probleme ab, aber du kannst noch weiter gehen. Wenn deine Config in statischen Dateien liegt, validiere sie zur Build-Zeit.

Betrachte eine JSON-Config-Datei, die Feature-Flags pro Umgebung definiert:

{
  "features": {
    "newCheckout": {
      "enabled": true,
      "rolloutPercentage": 50
    }
  }
}

Definiere ein Schema, dann validiere die JSON-Datei als Teil deines Build-Steps:

import { z } from "zod";
import featureFlags from "./feature-flags.json";

const FeatureFlagConfig = z.object({
  features: z.record(
    z.object({
      enabled: z.boolean(),
      rolloutPercentage: z.number().min(0).max(100),
    })
  ),
});

// This runs during `tsc` or `vite build` if you import the result
export const validatedFlags = FeatureFlagConfig.parse(featureFlags);

TypeScript wird sich weigern zu kompilieren, wenn die JSON-Datei malformed ist. Der Fehler kommt in CI, nicht in der Produktion.

Für Umgebungsvariablen, die inhärent Runtime-Daten sind, kannst du nicht vollständig zur Build-Zeit validieren. Aber du kannst einen type-safe Accessor generieren, der beim Startup sofort failed:

function env(name: string, schema: z.ZodTypeAny): z.infer<typeof schema> {
  const value = process.env[name];
  const result = schema.safeParse(value);
  if (!result.success) {
    console.error(`Invalid env var ${name}:`, result.error.flatten());
    process.exit(1);
  }
  return result.data;
}

const dbUrl = env("DATABASE_URL", z.string().url());

Dieses Pattern zentralisiert den Failure. Es gibt genau einen Ort, an dem eine fehlende oder ungültige Umgebungsvariable einen Crash verursacht, und das passiert, bevor dein Server Traffic annimmt.

Trade-offs: Striktheit versus Deployment-Friction

Schema-Validierung ist nicht kostenlos. Die Hauptkosten sind Deployment-Friction.

Wenn dein Schema zu strikt ist, verhindert eine neue Umgebungsvariable, die zwar zum Staging aber noch nicht zur Produktion hinzugefügt wurde, Deploys. Ein Teammate fügt FEATURE_X_ENABLED=false zur Staging-.env hinzu und pusht Code, der sie liest. Die Produktion crashed beim Startup, weil die Variable dort fehlt.

Du hast zwei Möglichkeiten, damit umzugehen.

Erstens, verwende explizite Defaults für Variablen, die sichere Fallbacks haben:

const config = z.object({
  LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
  ENABLE_METRICS: z.string().transform((s) => s === "true").default("false"),
});

Zweitens, trenne required von optionaler Config klar. Required Fields sollten Dinge repräsentieren, ohne die die Anwendung genuin nicht operieren kann. Optionale Fields sollten Defaults haben oder als deaktivierte Features behandelt werden, wenn sie fehlen.

Ein weiterer Trade-off ist die Größe der Schema-Library. Zod fügt deinem Bundle etwa 10KB hinzu. Wenn du in einer constrained Environment bist, erwäge Valibot oder ArkType, die ähnliche APIs mit kleinerem Footprint bieten. Das Pattern zählt mehr als die Library.

Wann generierte Typen nicht ausreichen

Manche Teams generieren TypeScript-Typen aus ihren Config-Dateien und nennen das validiert. Eine Typdefinition erzwingt zur Runtime nichts. Wenn eine JSON-Datei ihre Form ändert, nachdem die Typen generiert wurden, fängt TypeScript das nicht ab, außer die Datei wird importiert und während der Kompilierung geprüft.

Runtime-Parsing schließt die Lücke. Das Schema ist sowohl der Validator als auch die Source of Truth für Typen. Es gibt keine separate Typdefinition, die drift out of sync gehen kann.

Zusammenführung in einem Bounded Context

Hier ist ein komplettes Pattern für einen einzelnen Context:

// contexts/billing/config.ts
import { z } from "zod";

const RawBillingConfig = z.object({
  STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
  STRIPE_WEBHOOK_SECRET: z.string(),
  INVOICE_GRACE_PERIOD_DAYS: z
    .string()
    .transform(Number)
    .pipe(z.number().min(0)),
});

export type BillingConfig = z.infer<typeof RawBillingConfig>;

export function loadBillingConfig(): BillingConfig {
  const raw = {
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
    INVOICE_GRACE_PERIOD_DAYS: process.env.INVOICE_GRACE_PERIOD_DAYS,
  };

  const result = RawBillingConfig.safeParse(raw);
  if (!result.success) {
    const issues = result.error.issues
      .map((i) => `${i.path.join(".")}: ${i.message}`)
      .join("\n");
    throw new Error(`Billing config invalid:\n${issues}`);
  }

  return result.data;
}

Importiere loadBillingConfig() in deinen Entry Point, rufe ihn einmal auf und übergib das Resultat an die Initialisierung deines Contexts. Kein Modul außerhalb dieses Contexts sollte process.env direkt lesen.

Was als Nächstes zu tun ist

Auditiere deine Codebase auf direkte process.env-Reads außerhalb von Config-Dateien. Jeder einzelne ist ein potenzieller Runtime-Failure, der auf den richtigen Deployment wartet, um ausgelöst zu werden.

Ersetze sie durch pro-Context-Schemas. Wähle eine Library, definiere die Shapes und fail schnell beim Startup. Das erste Mal, wenn eine fehlende Umgebungsvariable deine Anwendung sauber in CI beendet, anstatt in der Produktion zu werfen, weißt du, dass die Boundary funktioniert.