staging環境は動作している。production環境は動作していない。両者の.envファイルの差分は400行で、その半数はもう誰も信じていないコメントだ。先月、誰かがstagingにFEATURE_X_ENABLED=trueを追加した。productionには誰も追加しなかった。アプリケーションはそれでも起動し、ハードコードされたデフォルト値を使用して、今や機能フラグが同期していない。

これが環境間設定の標準的な状態だ。これはシークレット管理の問題ではない。Terraformがないからでもない。これは言語の問題だ。構造化され、環境に依存した設定を、構造や環境、依存関係の概念を持たない形式で表現している。

フラットなキーバリューファイルはスケールすると腐敗する

環境変数とフラットなYAMLファイルは同じ欠陥を持つ。設定を区別のない文字列の袋として扱う。すべての環境で同じであるべき値と、意図的に異なる値と、誤って欠落している値を区別できない。

典型的なセットアップでは、staging.envproduction.envは互いのコピーとして始まる。時間が経つにつれて分岐する。チームメンバーが機能をテストするためにstagingに変数を追加する。別のメンバーがdeploymentが緊急だったため、productionのキー名を変更するがstagingは変更しない。3人目のメンバーが、変数が一方のファイルから欠落しているためコードでデフォルト値を仮定するが、そのデフォルト値は他の環境には誤りである。

腐敗はインシデントを引き起こすまで見えない。その時点までに、.envファイルは書き込み専用のアーティファクトになっている。何かを壊すことへの恐怖がクリーンアップを妨げる。混乱は増大する。

なぜ境界づけられたコンテキストに独自の設定言語が必要か

ドメイン駆動設計(Domain-Driven Design)は、システムを境界づけられたコンテキスト(bounded context)に分割する。各コンテキストは独自のモデルと言語を持つ。設定も同じ境界に従うべきだ。決済サービスはSTRIPE_WEBHOOK_SECRETINVOICE_GRACE_PERIOD_DAYSを気にする。通知サービスはSNS_TOPIC_ARNRATE_LIMIT_PER_MINUTEを気にする。両者の設定の形は共通点がないので、設定ファイルを共有すべきではない。

ほとんどのチームが見落とす洞察:設定は値だけではない。それはスキーマと、デフォルト値の集合と、環境固有の上書き値の集合だ。3つすべてを単一のフラットファイルとして扱うと、いずれについても推論する能力を失う。

境界づけられたコンテキストごとの設定DSLは構造を明示的にする。共有デフォルト値を環境上書き値から分離し、アプリケーション起動前にマージ結果を検証する。

TypeScriptで動作する設定DSL

以下はコンテキストごとに設定を定義する小さな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はスキーマ、ベース設定オブジェクト、環境上書き値のマップを受け取る。環境名を受け取り、ベース値を上書き値とマージして結果を検証する関数を返す。必須フィールドが欠落しているか型が誤っている場合、サーバー起動前にパースが例外を投げる。

次に、境界づけられたコンテキストがそれを使用する:

// 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はproductionを除いてどこでもデフォルトで3だ。productionでは明示的に5に上書きされている。logLevelはdevelopmentでは"debug"で、それ以外では"info"だ。ベース値が"info"で、developmentだけが上書きしているからだ。共有値の重複はない。どのファイルが正規のデフォルト値を持っているか推測する必要はない。

誰かがstagingにSTRIPE_SECRET_KEYを設定し忘れた場合、アプリケーションは起動時に明確なエラーとともに終了する。障害はCIで発生し、productionでは発生しない。

これが設定の漂流を止める仕組み

漂流は2つの環境が誤って分岐するときに起こる。DSLは3つの方法でこれを防ぐ。

第一に、共有値は1か所、baseオブジェクトに置かれる。デフォルトのリトライポリシーを変更する場合、1行を変更するだけだ。明示的な上書き値がない限り、すべての環境が自動的にそれを取得する。

第二に、環境上書き値は完全なオブジェクトであり、ファイル間の行単位の差分ではない。1つのオブジェクトを見ればベースと異なるすべての値がわかる。2つの.envファイルをdiffしてどの行が重要か推測する必要はない。

第三に、スキーマが完全性を強制する。スキーマに新しい必須フィールドを追加すると、それを提供しないすべての環境オブジェクトは検証に失敗する。productionにフィールドを追加してstagingを忘れることはできない。コンパイラと検証器が思い出させてくれる。

トレードオフ:明示的な構造には明示的な労力がかかる

このパターンはタダではない。事前の設計が必要だ。誰かがスキーマを定義し、どの値がベースでどの値が上書き値か決定し、DSLの仕組みを維持しなければならない。

チームが小さく、設定が10変数程度なら、.envファイルで十分だ。スキーマとマージ層のオーバーヘッドは、漂流の痛みを感じるまでは値しない。

もう1つのコストは過度の抽象化への誘惑だ。DSLは組織のすべてのサービスを対象とする汎用設定フレームワークに成長しうる。これに抵抗せよ。このパターンの全要点は、設定を各境界づけられたコンテキストに局所化することだ。決済DSLと通知DSLが同じスキーマに強制されたら、追加の手順を経てモノリシックな.envファイルを再作成したことになる。

シークレットも考慮事項だ。例ではprocess.envからSTRIPE_SECRET_KEYを読むが、vaultから取得してもよい。DSLは値の出所を気にしない。アプリケーションがそれらを使用する前にスキーマと一致することだけを気にする。

チームがこの手法を採用する際に間違えること

最も一般的な間違いは、スキーマをオプションのドキュメントとして扱うことだ。チームはTypeScriptインターフェースを定義しても、実行時に決して検証しない。インターフェースは約束であって、チェックではない。stagingの環境変数が欠落していても、TypeScriptはそれを捕捉しない。process.envは実行時に設定されるからだ。

検証は、いかなるリクエストが処理される前の起動時に行われなければならない。スキーマは型と値の両方の信頼できる情報源(source of truth)だ。

2番目の間違いは、環境を1つの設定定義に統合するのが早すぎることだ。1つの境界づけられたコンテキストから始めよ。その設定をスキーマと上書き値に抽出する。このパターンが自分自身を証明するのを待て。

まとめ:3段階の移行

.envファイルの山から始める場合、ビッグバン書き換えを必要としない道がここにある。

ステップ1:設定関連のインシデントが最も多い境界づけられたコンテキストを選ぶ。そこが最も苦痛が大きく、チームが最も速く恩恵を感じる場所だ。

ステップ2:そのコンテキストが読むすべての設定値を棚卸しする。シークレット、環境固有の非シークレット、共有デフォルト値に分類する。形状と制約を捉えるZodスキーマを書く。

ステップ3:そのコンテキストでの直接のprocess.env読み取りを、スキーマと上書き値を使用する単一のloadConfig()呼び出しに置き換える。1つの環境にデプロイし、CIで欠落値を捕捉するのを見て、修正する。検証は即座にその価値を証明する。

次のコンテキストでも繰り返す。3〜4コンテキスト後、このパターンは自立的になる。チームは指示されなくてもこれを採用する。インシデントを防ぐのを見たからだ。

FAQ

設定DSLとは何ですか?

設定DSLは、境界づけられたコンテキスト内で設定を定義するための小さなドメイン固有言語だ。通常、スキーマ、ベース値、環境上書き値を含む。設定を明示的にし、実行前に検証するのに十分な構造だ。

なぜ集中設定サービスを使わないのですか?

集中サービスは、機能フラグのような動的値に有用だ。APIエンドポイントやリトライポリシーのような静的設定は、実行時に変更されることはめったにない。リモートサービスから取得すると、起動にネットワーク依存性が追加される。DSLパターンは静的設定をローカルに処理し、動的値はサービスに任せる。

シークレットはどう扱いますか?

シークレットは環境変数やvaultを通じてシステムに入るべきだが、それでもスキーマ検証器を通過すべきだ。スキーマでは必須文字列として定義する。環境上書きオブジェクト内でprocess.envやvaultクライアントから読み取る。シークレットが欠落している場合、検証が失敗し、アプリケーションは正常に終了する。

TypeScriptなしで使えますか?

はい。このパターンは検証ライブラリを持つあらゆる言語で動作する。PythonにはPydanticがある。Rustにはserdevalidatorがある。Goにはgo-playground/validatorがある。DSL層は、ベースオブジェクトを上書き値とマージして結果を検証する関数に過ぎない。

これはいつ過剰ですか?

アプリケーションの設定値が1ダース未満で環境が1つなら、.envファイルの方がシンプルだ。複数の環境、複数のチーム、または設定関連のインシデントの歴史がある場合に、このDSLパターンを採用せよ。

次にやるべきこと

現在の.envファイルや設定YAMLを監査し、すべての環境で同一の値を探す。それらがベースデフォルト値だ。異なるものはすべて上書き値だ。1つの境界づけられたコンテキストを選び、そのスキーマを定義し、それらの値を差異を明示的にする構造に移動させる。CIが欠落したstaging変数をproductionに到達する前に捕捉した最初の時点で、境界は機能している。