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.
| Prioritas | Sumber | Cocok untuk | Contoh |
|---|---|---|---|
| 1 (rendah) | Defaults di kode | Nilai default stabil | port = 8080 |
| 2 | File config (HOCON/YAML) | Env-spesifik (dev/staging/prod) | application.conf |
| 3 | System properties (-D) | Override development | -Dapp.port=9090 |
| 4 | Environment variables | Secrets, 12-factor app | DATABASE_URL |
| 5 (tinggi) | CLI args | Eksperimen 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:
// 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:
=== 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 startupMari kita bedah hasil merging di atas:
- Default:
app.port = 8080dariDefaults.PORT. Nilai ini aman karena selalu ada. - File Config mengubah
app.portmenjadi9090,app.db.urlke PostgreSQL production,app.db.usermenjadiapp_user, danapp.log.levelmenjadiDEBUG. - System Properties override
app.portke8081danapp.feature.xkefalse. CLI args (--app.port=3000) nantinya akan menimpa kembali. - Environment Variables (
APP_PORT,APP_DB_USER) ditambahkan ke merged map. Catatan: env vars biasanya memakaiSCREAMING_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. - CLI Args memiliki prioritas tertinggi —
app.portakhirnya3000,app.log.levelmenjadiTRACE. - 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. - 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:
| Format | Contoh penggunaan | Kelebihan | Kekurangan |
|---|---|---|---|
| HOCON | Typesafe Config (Akka, Play) | Sintaks ringkas, mendukung substitution | Tidak standar industri |
| YAML | Spring Boot (application.yml) | Populer, manusia-friendly | Sensitif indentasi |
| TOML | CLI tools, ktor 3.x | Standar, tipe data jelas | Tidak punya nested object luas |
HOCON #
HOCON (Human-Optimized Config Object Notation) adalah superset JSON yang lebih ringkas:
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):
app:
port: 8080
db:
url: jdbc:postgresql://localhost/app
user: app_user
log:
level: INFOTOML #
TOML (Tom’s Obvious Minimal Language) populer untuk CLI tools karena tipe data eksplisit:
[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.