Aplikasi production jarang berjalan dengan satu set konfigurasi tunggal. Port HTTP berbeda antara development (8080) dan produksi (443), database URL berbeda per environment, feature flag berubah tanpa rebuild. Pola yang sehat adalah mendefinisikan konfigurasi berlapis — defaults di kode sebagai fallback, file config untuk env-spesifik, system properties untuk override ad-hoc, environment variables untuk secrets, dan CLI args untuk eksperimen. Pada chapter ini kita akan membedah urutan override, mendemokan loader konfigurasi yang menggabungkan lima sumber, serta membandingkan format file populer: HOCON (Typesafe Config), YAML, dan TOML.

Urutan Override Konfigurasi #

Prinsip utama: sumber yang lebih spesifik menimpa sumber yang lebih umum. Default berada paling bawah (paling umum), CLI args paling atas (paling spesifik). Antara keduanya, file config, system properties, dan environment variables.

PrioritasSumberCocok untukContoh
1 (rendah)Defaults di kodeNilai default stabilport = 8080
2File config (HOCON/YAML)Env-spesifik (dev/staging/prod)application.conf
3System properties (-D)Override development-Dapp.port=9090
4Environment variablesSecrets, 12-factor appDATABASE_URL
5 (tinggi)CLI argsEksperimen ad-hoc--app.port=3000

Konfigurasi di atasnya menimpa yang di bawah. Dengan urutan ini, default aman, tetapi operator production selalu bisa override tanpa rebuild kode.

Demo Loader Konfigurasi #

Cuplikan berikut mengimplementasikan loader yang menggabungkan lima sumber dengan urutan override yang benar:

kotlin
// runtime_configuration.kt — Demo konfigurasi runtime via env vars, properties, dan file

import java.util.Properties
import java.io.StringReader

// === Konfigurasi default ===
object Defaults {
    const val APP_NAME = "demo-app"
    const val PORT = 8080
    const val DB_URL = "jdbc:h2:mem:demo"
    const val DB_USER = "sa"
    const val LOG_LEVEL = "INFO"
    const val FEATURE_X = true
}

// === Representasi konfigurasi aplikasi ===
data class AppConfig(
    val appName: String,
    val port: Int,
    val dbUrl: String,
    val dbUser: String,
    val logLevel: String,
    val featureX: Boolean
)

// === Loader konfigurasi dengan urutan override ===
class ConfigLoader(
    private val defaults: Map<String, String>,
    private val fileConfig: Map<String, String> = emptyMap(),
    private val systemProps: Map<String, String> = emptyMap(),
    private val envVars: Map<String, String> = emptyMap(),
    private val cliArgs: Map<String, String> = emptyMap()
) {
    // Urutan prioritas (rendah -> tinggi):
    // 1. Defaults
    // 2. File config
    // 3. System properties (-D...)
    // 4. Environment variables
    // 5. CLI args (--key=value)
    fun load(): Map<String, String> {
        val merged = defaults.toMutableMap()
        fileConfig.forEach { (k, v) -> merged[k] = v }
        systemProps.forEach { (k, v) -> merged[k] = v }
        envVars.forEach { (k, v) -> merged[k] = v }
        cliArgs.forEach { (k, v) -> merged[k] = v }
        return merged
    }
}

// === Parser HOCON ringkas (mock) ===
fun parseHoconLike(text: String): Map<String, String> {
    val result = mutableMapOf<String, String>()
    text.lines().forEach { line ->
        val trimmed = line.trim()
        if (trimmed.isEmpty() || trimmed.startsWith("#") || trimmed.startsWith("//")) return@forEach
        val eq = trimmed.indexOf('=')
        if (eq > 0) {
            val key = trimmed.substring(0, eq).trim()
            var value = trimmed.substring(eq + 1).trim()
            // Hapus quote
            if (value.startsWith('"') && value.endsWith('"')) {
                value = value.substring(1, value.length - 1)
            }
            result[key] = value
        }
    }
    return result
}

// === Parser argumen CLI ===
fun parseCliArgs(args: Array<String>): Map<String, String> {
    val result = mutableMapOf<String, String>()
    for (arg in args) {
        if (arg.startsWith("--")) {
            val eq = arg.indexOf('=')
            if (eq > 0) {
                val key = arg.substring(2, eq)
                val value = arg.substring(eq + 1)
                result[key] = value
            }
        }
    }
    return result
}

fun main() {
    println("=== Runtime Configuration ===")
    println()
    println("Urutan override: defaults < file < system props < env vars < CLI args.")
    println()

    // === 1. Defaults ===
    println("=== 1. Default Config ===")
    val defaults = mapOf(
        "app.name" to Defaults.APP_NAME,
        "app.port" to Defaults.PORT.toString(),
        "app.db.url" to Defaults.DB_URL,
        "app.db.user" to Defaults.DB_USER,
        "app.log.level" to Defaults.LOG_LEVEL,
        "app.feature.x" to Defaults.FEATURE_X.toString()
    )
    defaults.forEach { (k, v) -> println("  " + k.padEnd(20) + " = " + v) }
    println()

    // === 2. File config (HOCON-like) ===
    println("=== 2. File Config (application.conf) ===")
    val hocon = """
        # Konfigurasi produksi
        app.port = 9090
        app.db.url = "jdbc:postgresql://db.example.com/app"
        app.db.user = "app_user"
        app.log.level = "DEBUG"
    """.trimIndent()
    val fileConfig = parseHoconLike(hocon)
    fileConfig.forEach { (k, v) -> println("  " + k.padEnd(20) + " = " + v) }
    println()

    // === 3. System properties ===
    println("=== 3. System Properties ===")
    val systemProps = mapOf(
        "app.port" to "8081",
        "app.feature.x" to "false"
    )
    systemProps.forEach { (k, v) -> println("  -D" + k.padEnd(20) + " = " + v) }
    println()

    // === 4. Environment variables ===
    println("=== 4. Environment Variables ===")
    val envVars = mapOf(
        "APP_PORT" to "8082",
        "APP_DB_USER" to "prod_user"
    )
    envVars.forEach { (k, v) -> println("  " + k.padEnd(20) + " = " + v) }
    println()

    // === 5. CLI args ===
    println("=== 5. CLI Args ===")
    val cliArgs = arrayOf("--app.port=3000", "--app.log.level=TRACE")
    val cli = parseCliArgs(cliArgs)
    cli.forEach { (k, v) -> println("  --" + k.padEnd(20) + " = " + v) }
    println()

    // === 6. Merged config ===
    println("=== 6. Merged Config (final) ===")
    val loader = ConfigLoader(defaults, fileConfig, systemProps, envVars, cli)
    val merged = loader.load()
    merged.toSortedMap().forEach { (k, v) -> println("  " + k.padEnd(20) + " = " + v) }
    println()

    // === 7. Bangun AppConfig ===
    println("=== 7. AppConfig (typed) ===")
    val config = AppConfig(
        appName = merged["app.name"] ?: Defaults.APP_NAME,
        port = (merged["app.port"] ?: Defaults.PORT.toString()).toInt(),
        dbUrl = merged["app.db.url"] ?: Defaults.DB_URL,
        dbUser = merged["app.db.user"] ?: Defaults.DB_USER,
        logLevel = merged["app.log.level"] ?: Defaults.LOG_LEVEL,
        featureX = (merged["app.feature.x"] ?: Defaults.FEATURE_X.toString()).toBoolean()
    )
    println("  $config")
    println()

    // === 8. Aturan praktis ===
    println("=== 8. Aturan Praktis ===")
    val rules = listOf(
        "1. Defaults di kode sebagai fallback terakhir",
        "2. File config (HOCON/YAML/TOML) untuk env-spesifik",
        "3. System properties untuk override development",
        "4. Environment variables untuk secrets (12-factor app)",
        "5. CLI args untuk eksperimen ad-hoc",
        "6. Validasi dan fail-fast pada startup"
    )
    rules.forEach { println("  " + it) }
}

Output yang Dihasilkan:

text
=== Runtime Configuration ===

Urutan override: defaults < file < system props < env vars < CLI args.

=== 1. Default Config ===
  app.name             = demo-app
  app.port             = 8080
  app.db.url           = jdbc:h2:mem:demo
  app.db.user          = sa
  app.log.level        = INFO
  app.feature.x        = true

=== 2. File Config (application.conf) ===
  app.port             = 9090
  app.db.url           = jdbc:postgresql://db.example.com/app
  app.db.user          = app_user
  app.log.level        = DEBUG

=== 3. System Properties ===
  -Dapp.port             = 8081
  -Dapp.feature.x        = false

=== 4. Environment Variables ===
  APP_PORT             = 8082
  APP_DB_USER          = prod_user

=== 5. CLI Args ===
  --app.port             = 3000
  --app.log.level        = TRACE

=== 6. Merged Config (final) ===
  APP_DB_USER          = prod_user
  APP_PORT             = 8082
  app.db.url           = jdbc:postgresql://db.example.com/app
  app.db.user          = app_user
  app.feature.x        = false
  app.log.level        = TRACE
  app.name             = demo-app
  app.port             = 3000

=== 7. AppConfig (typed) ===
  AppConfig(appName=demo-app, port=3000, dbUrl=jdbc:postgresql://db.example.com/app, dbUser=app_user, logLevel=TRACE, featureX=false)

=== 8. Aturan Praktis ===
  1. Defaults di kode sebagai fallback terakhir
  2. File config (HOCON/YAML/TOML) untuk env-spesifik
  3. System properties untuk override development
  4. Environment variables untuk secrets (12-factor app)
  5. CLI args untuk eksperimen ad-hoc
  6. Validasi dan fail-fast pada startup

Mari kita bedah hasil merging di atas:

  1. Default: app.port = 8080 dari Defaults.PORT. Nilai ini aman karena selalu ada.
  2. File Config mengubah app.port menjadi 9090, app.db.url ke PostgreSQL production, app.db.user menjadi app_user, dan app.log.level menjadi DEBUG.
  3. System Properties override app.port ke 8081 dan app.feature.x ke false. CLI args (--app.port=3000) nantinya akan menimpa kembali.
  4. Environment Variables (APP_PORT, APP_DB_USER) ditambahkan ke merged map. Catatan: env vars biasanya memakai SCREAMING_SNAKE_CASE, bukan dot notation. Aplikasi nyata harus menormalisasi nama (mis. APP_PORT → app.port) sebelum merge. Demo ini membiarkan keduanya terpisah agar tampak jelas.
  5. CLI Args memiliki prioritas tertinggi — app.port akhirnya 3000, app.log.level menjadi TRACE.
  6. AppConfig typed: hasil merged dipetakan ke data class AppConfig. Konversi tipe (toInt, toBoolean) dilakukan di sini. Bila parsing gagal, aplikasi harus fail-fast dengan pesan jelas.
  7. Aturan: 12-factor app menempatkan secrets di env vars, bukan di file config yang masuk repository.

Format File Konfigurasi #

Tiga format populer di ekosistem JVM/Kotlin:

FormatContoh penggunaanKelebihanKekurangan
HOCONTypesafe Config (Akka, Play)Sintaks ringkas, mendukung substitutionTidak standar industri
YAMLSpring Boot (application.yml)Populer, manusia-friendlySensitif indentasi
TOMLCLI tools, ktor 3.xStandar, tipe data jelasTidak punya nested object luas

HOCON #

HOCON (Human-Optimized Config Object Notation) adalah superset JSON yang lebih ringkas:

hocon application.conf
app {
  port = 8080
  db {
    url = "jdbc:postgresql://localhost/app"
    user = "app_user"
  }
  log.level = "INFO"
}

# Substitution
app.db.full = ${app.db.url}?user=${app.db.user}

Library typesafe-config membaca HOCON dan mendukung substitution ${...} serta include include "other.conf".

YAML #

YAML populer di Spring Boot. Indentasi signifikan (tab dilarang, hanya spasi):

yaml application.yml
app:
  port: 8080
  db:
    url: jdbc:postgresql://localhost/app
    user: app_user
  log:
    level: INFO

TOML #

TOML (Tom’s Obvious Minimal Language) populer untuk CLI tools karena tipe data eksplisit:

toml config.toml
[app]
port = 8080
log_level = "INFO"

[app.db]
url = "jdbc:postgresql://localhost/app"
user = "app_user"

Referensi Lanjutan #

  • Spring Boot — dokumentasi Spring Boot untuk runtime configuration externalized

Selanjutnya #

Setelah memahami urutan override konfigurasi, kita perlu membahas secara khusus environment variables — sumber yang paling sesuai untuk secrets dan konfigurasi yang berubah per deployment. Materi tersebut ada pada chapter Environment Variables.