Environment staging-mu berfungsi. Environment production-mu tidak. Diff antara file .env mereka adalah 400 baris, dan setengah dari baris tersebut adalah komentar yang tidak lagi dipercaya siapa pun. Seseorang menambahkan FEATURE_X_ENABLED=true ke staging bulan lalu. Tidak ada yang menambahkannya ke production. Aplikasi tetap berjalan, menggunakan default yang hardcoded, dan sekarang feature flags-mu tidak sinkron.

Ini adalah keadaan standar config cross-environment. Ini bukan masalah secret management. Ini bukan kurangnya Terraform. Ini adalah masalah bahasa. Kamu mengekspresikan config yang terstruktur dan bergantung pada environment dalam format yang tidak memiliki konsep struktur, environment, atau dependency.

File key-value flat membusuk pada skala besar

Environment variables dan file YAML flat memiliki kelemahan yang sama: mereka memperlakukan config sebagai kumpulan string yang tidak dibedakan. Tidak ada perbedaan antara nilai yang harus identik di semua environment, nilai yang sengaja berbeda, dan nilai yang hilang secara tidak sengaja.

Dalam setup yang tipikal, staging.env dan production.env dimulai sebagai salinan satu sama lain. Seiring waktu mereka menyimpang. Seorang rekan menambahkan variabel ke staging untuk menguji sebuah feature. Rekan lain mengubah nama key di production tetapi tidak di staging karena deployment-nya mendesak. Rekan ketiga mengasumsikan default dalam kode karena variabel hilang dari satu file, dan default tersebut salah untuk environment lainnya.

Pembusukan tidak terlihat sampai menyebabkan incident. Pada saat itu, file .env-mu telah menjadi artefak write-only. Ketakutan untuk merusak sesuatu mencegah pembersihan. Kekacauan bertambah.

Mengapa bounded contexts memerlukan bahasa config mereka sendiri

Domain-Driven Design membagi sistem menjadi bounded contexts, masing-masing dengan model dan bahasanya sendiri. Config harus mengikuti batas yang sama. Sebuah payment service peduli pada STRIPE_WEBHOOK_SECRET dan INVOICE_GRACE_PERIOD_DAYS. Sebuah notification service peduli pada SNS_TOPIC_ARN dan RATE_LIMIT_PER_MINUTE. Bentuk config mereka tidak memiliki kesamaan, jadi mereka tidak boleh berbagi file config.

Wawasan yang kebanyakan tim lewatkan: config bukan hanya values. Itu adalah schema, seperangkat defaults, dan seperangkat overrides khusus environment. Ketika kamu memperlakukan ketiganya sebagai satu file flat, kamu kehilangan kemampuan untuk bernalar tentang masing-masing.

Sebuah config DSL per bounded context membuat struktur eksplisit. Itu memisahkan shared defaults dari environment overlays dan memvalidasi hasil merged sebelum aplikasi dimulai.

Sebuah config DSL yang berfungsi dalam TypeScript

Berikut adalah DSL kecil yang mendefinisikan config per konteks. Ini menggunakan Zod untuk validation dan plain objects untuk environment overlays. Pattern-nya sama dalam Python, Rust, atau Go. Hanya validation library yang berubah.

Pertama, mesin 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 mengambil sebuah schema, objek config base, dan map dari environment overrides. Ini mengembalikan fungsi yang mengambil nama environment, melakukan merge base dengan overlay, dan memvalidasi hasilnya. Jika required field hilang atau type salah, parse akan melempar error sebelum server-mu dimulai.

Sekarang bounded context menggunakannya:

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

Perhatikan apa yang terjadi di sini. retryAttempts default ke 3 di mana-mana kecuali production, di mana secara eksplisit di-override ke 5. logLevel adalah "debug" di development dan "info" di tempat lain karena nilai base adalah "info" dan hanya development yang meng-overridenya. Tidak ada duplikasi shared values. Tidak ada tebakan file mana yang memiliki default kanonik.

Jika seseorang lupa mengatur STRIPE_SECRET_KEY di staging, aplikasi keluar saat startup dengan error yang jelas. Failure terjadi di CI, bukan di production.

Bagaimana ini menghentikan config drift

Drift terjadi ketika dua environment menyimpang secara tidak sengaja. DSL mencegahnya dengan tiga cara.

Pertama, shared values tinggal di satu tempat: objek base. Jika kamu mengubah retry policy default, kamu mengubah satu baris. Setiap environment mendapatkannya secara otomatis kecuali memiliki override eksplisit.

Kedua, environment overrides adalah objek lengkap, bukan diffs tingkat baris antar file. Kamu dapat melihat setiap nilai yang berbeda dari base dengan melihat satu objek. Tidak perlu diff dua file .env dan menebak baris mana yang penting.

Ketiga, schema menegakkan completeness. Jika kamu menambahkan required field baru ke schema, setiap objek environment yang tidak menyediakannya akan gagal validation. Kamu tidak dapat menambahkan field ke production dan melupakan staging. Compiler dan validator akan mengingatkanmu.

Trade-off: struktur eksplisit membutuhkan upaya eksplisit

Pattern ini tidak gratis. Ini memerlukan upfront design. Seseorang harus mendefinisikan schema, memutuskan nilai mana yang base dan mana yang overrides, dan menjaga mesin DSL tetap maintained.

Jika timmu kecil dan config-mu berjumlah sepuluh variabel, file .env sudah cukup. Overhead dari schema dan merge layer tidak sepadan sampai kamu merasakan sakitnya drift.

Biaya lain adalah godaan untuk over-abstract. Sebuah DSL dapat tumbuh menjadi config framework general-purpose yang mencakup setiap service di org-mu. Lawanlah ini. Seluruh poin dari pattern adalah menjaga config tetap lokal untuk setiap bounded context. Jika config DSL payment-mu dan notification-mu dipaksa ke dalam schema yang sama, kamu telah menciptakan ulang file .env monolitik dengan langkah tambahan.

Secrets adalah pertimbangan lain. Contoh membaca STRIPE_SECRET_KEY dari process.env, tetapi kamu bisa mengambilnya dari vault. DSL tidak peduli dari mana values berasal. Ini hanya peduli bahwa mereka cocok dengan schema sebelum aplikasi menggunakannya.

Kesalahan yang dilakukan tim saat mengadopsi ini

Kesalahan paling umum adalah memperlakukan schema sebagai dokumentasi opsional. Tim mendefinisikan interface TypeScript dan tidak pernah memvalidasi saat runtime. Sebuah interface adalah janji, bukan check. Jika environment variable staging hilang, TypeScript tidak akan menangkapnya karena process.env di-populate saat runtime.

Validation harus terjadi saat startup, sebelum request apa pun dilayani. Schema adalah source of truth untuk types dan values.

Kesalahan kedua adalah menggabungkan environment ke dalam definisi config tunggal terlalu dini. Mulailah dengan satu bounded context. Ekstrak config-nya ke dalam schema dan overlays. Biarkan pattern membuktikan dirinya sendiri.

Menyatukannya: migration tiga langkah

Jika kamu mulai dari tumpukan file .env, berikut adalah jalur yang tidak memerlukan big-bang rewrite.

Langkah satu: pilih bounded context dengan incident terkait config terbanyak. Itulah tempat rasa sakit tertinggi dan tim akan merasakan benefit tercepat.

Langkah dua: buat inventaris setiap config value yang dibaca konteks tersebut. Sortir menjadi secrets, non-secrets khusus environment, dan shared defaults. Tulis schema Zod yang menangkap shapes dan constraints.

Langkah tiga: ganti pembacaan process.env langsung di konteks tersebut dengan satu panggilan loadConfig() yang menggunakan schema dan overlays. Deploy ke satu environment, amati bagaimana ia menangkap nilai yang hilang di CI, dan perbaiki. Validation akan segera membuktikan nilainya.

Ulangi untuk konteks berikutnya. Setelah tiga atau empat konteks, pattern menjadi self-sustaining. Tim mengadopsinya tanpa disuruh karena mereka telah melihatnya mencegah incident.

FAQ

Apa itu config DSL?

Sebuah config DSL adalah domain-specific language kecil untuk mendefinisikan konfigurasi dalam bounded context. Biasanya mencakup schema, base values, dan environment overlays. Ini cukup struktur untuk membuat config eksplisit dan memvalidasinya sebelum runtime.

Mengapa tidak menggunakan config service yang terpusat?

Sebuah layanan terpusat berguna untuk nilai dinamis seperti feature flags. Config statis, seperti API endpoints dan retry policies, jarang berubah saat runtime. Mengambilnya dari layanan remote menambahkan network dependency ke startup. Pattern DSL menangani config statis secara lokal dan membiarkan nilai dinamis ke layanan.

Bagaimana cara menangani secrets?

Secrets harus memasuki sistem melalui environment variables atau vault, tetapi mereka tetap harus melewati schema validator. Definisikan sebagai required strings dalam schema. Baca dari process.env atau klien vault di dalam objek environment overlay. Jika secret hilang, validation gagal dan aplikasi keluar dengan bersih.

Bisakah saya menggunakan ini tanpa TypeScript?

Ya. Pattern ini berfungsi dalam bahasa apa pun dengan validation library. Python memiliki Pydantic. Rust memiliki serde dengan validator. Go memiliki go-playground/validator. Lapisan DSL hanyalah fungsi yang melakukan merge objek base dengan overlay dan memvalidasi hasilnya.

Kapan ini menjadi overkill?

Jika aplikasimu memiliki kurang dari selusin config values dan satu environment, file .env lebih sederhana. Adopsi pattern DSL ketika kamu memiliki multiple environments, multiple teams, atau riwayat incident terkait config.

Apa yang harus dilakukan selanjutnya

Audit file .env atau config YAML saat ini untuk nilai-nilai yang identik di semua environment. Itulah base defaults-mu. Apa pun yang berbeda adalah overlay. Pilih satu bounded context, definisikan schema-nya, dan pindahkan nilai-nilai tersebut ke struktur yang membuat perbedaan eksplisit. Pertama kali CI-mu menangkap variable staging yang hilang sebelum mencapai production, batas tersebut berfungsi.