Did you know that you can navigate the posts by swiping left and right?
Every project I’ve worked on in banking and big data ends up with a config zoo: JSON for APIs, YAML for Kubernetes, properties for Spark, TOML for some Python tool. For application configuration there is one format I always come back to: HOCON (Human-Optimized Config Object Notation). Akka, Pekko, Play and scalafmt use it, and after years with it I think it simply beats the competition. Instead of listing features, let me show them.
HOCON is a superset of JSON, so any JSON file is already valid HOCON. Here is what it adds on top - the config of a made-up payments service, where the comments point at the features:
# application.conf - no root braces, no commas, comments welcome
include required("kafka.conf") # fails when missing
include "local.conf" # silently skipped when missing
defaults {
db {
driver = org.postgresql.Driver // quotes are optional
pool-size = 10
connection-timeout = 5 seconds
}
topics = [ payments, refunds ]
}
app {
env = dev
env = ${?APP_ENV} # environment variable wins when set
name = ledger-${app.env} # string concatenation
http.port = 8080 # path key: http { port = 8080 }
http { # same object again: merged, not replaced
host = "0.0.0.0"
port = ${?HTTP_PORT}
request-timeout = 30s
}
db = ${defaults.db} { # inherit a block and extend it
url = "jdbc:postgresql://localhost/"${app.name}
pool-size = 20
}
db.password = ${?DB_PASSWORD} # no variable, no key
topics = ${defaults.topics} [ chargebacks ] # array concatenation
topics += audit # append
statement-retention = 2 weeks # durations, periods
cache-size = 512MiB # and sizes are understood
daily-report = """
SELECT account_id, sum(amount)
FROM transactions
GROUP BY account_id
""" # multi-line string, no escaping
}
And the included file. Besides a plain name, include takes file(), classpath() and url():
# kafka.conf
kafka {
client-id = ${app.name} # substitutions see the whole config
properties {
"bootstrap.servers" = "localhost:9092" # quoted key, the dots stay
"bootstrap.servers" = ${?KAFKA_BROKERS}
"enable.auto.commit" = false
}
}
kafka.conf uses app.name before application.conf even defines it. Substitutions are resolved once everything is loaded and merged, so the order of files doesn’t matter.
In Scala I never read keys one by one. PureConfig derives a reader for a case class and maps kebab-case keys to camelCase fields. Put this file next to the two above and scala-cli turns them into a runnable demo:
//> using scala 3
//> using dep com.github.pureconfig::pureconfig-core:0.17.9
//> using dep com.lihaoyi::pprint:0.9.6
//> using resourceDir .
import pureconfig.*
import scala.concurrent.duration.FiniteDuration
import java.time.Period
import com.typesafe.config.ConfigMemorySize
case class Http(host: String, port: Int, requestTimeout: FiniteDuration) derives ConfigReader
case class Db(url: String, driver: String, poolSize: Int, connectionTimeout: FiniteDuration,
password: Option[String]) derives ConfigReader
case class App(name: String, http: Http, db: Db, topics: List[String],
statementRetention: Period, cacheSize: ConfigMemorySize, dailyReport: String) derives ConfigReader
case class Kafka(clientId: String, properties: Map[String, String]) derives ConfigReader
@main def show() =
pprint.pprintln(ConfigSource.default.at("app").loadOrThrow[App])
pprint.pprintln(ConfigSource.default.at("kafka").loadOrThrow[Kafka])
Now run it the way production would, with a few environment variables and one system property:
APP_ENV=prod HTTP_PORT=9090 DB_PASSWORD=s3cret KAFKA_BROKERS=kafka-1:9092,kafka-2:9092 \
scala-cli run . -Dapp.db.pool-size=50
App(
name = "ledger-prod",
http = Http(host = "0.0.0.0", port = 9090, requestTimeout = 30 seconds),
db = Db(
url = "jdbc:postgresql://localhost/ledger-prod",
driver = "org.postgresql.Driver",
poolSize = 50,
connectionTimeout = 5 seconds,
password = Some("s3cret")
),
topics = List("payments", "refunds", "chargebacks", "audit"),
statementRetention = P14D,
cacheSize = ConfigMemorySize(536870912),
dailyReport = """
SELECT account_id, sum(amount)
FROM transactions
GROUP BY account_id
"""
)
Kafka(
clientId = "ledger-prod",
properties = Map(
"enable.auto.commit" -> "false",
"bootstrap.servers" -> "kafka-1:9092,kafka-2:9092"
)
)
Look at what happened:
APP_ENV=prod changed name, db.url and kafka.clientId. Derived values follow the override, no templating engine involved.-Dapp.db.pool-size=50 won without any placeholder in the file. ConfigSource.default, which is ConfigFactory.load() underneath, stacks system properties over application.conf over the reference.conf of every jar on the classpath: libraries ship their defaults, you override only what you need. Add -Dconfig.override_with_env_vars=true and plain environment variables work the same way: CONFIG_FORCE_app_db_pool__size=50.db took driver and connectionTimeout from defaults.db and overrode poolSize. topics extended the default list twice.30s, 2 weeks and 512MiB arrived as a FiniteDuration, a Period and bytes. No more guessing if it was timeoutMs or timeoutSeconds.DB_PASSWORD the key doesn’t exist at all and password is None."bootstrap.servers" stays a single key, so properties can go straight to the Kafka client.request-timeout = 30 sekunds, and the application won’t start. Every broken key is reported at once, with file, line and a hint: (application.conf @ file:.../application.conf: 23) Cannot convert '30 sekunds' to Duration: format error 30 sekunds. (try a number followed by any of ns, us, ms, s, m, h, d).| HOCON | JSON | YAML | TOML | properties | |
|---|---|---|---|---|---|
| Comments | ✓ | ✗ | ✓ | ✓ | ✓ |
| Reuse a block | ✓ | ✗ | anchors, one file | ✗ | ✗ |
| Deep merge | ✓ | ✗ | shallow << |
✗ | ✗ |
| Environment variables | ✓ | ✗ | ✗ | ✗ | ✗ |
| Includes | ✓ | ✗ | ✗ | ✗ | ✗ |
| Durations and sizes | ✓ | ✗ | ✗ | ✗ | ✗ |
| Multi-line strings | ✓ | ✗ | ✓ | ✓ | ✗ |
| Reads JSON as is | ✓ | ✓ | ✓ (1.2) | ✗ | ✗ |
Tools bolt some of it on - Spring and Docker Compose interpolate ${VAR} in YAML, Helm renders it with Go templates - but each one differently, and the file alone doesn’t tell you what the application gets.
countries: [GB, NO] as ["GB", false] - the famous Norway problem. And version: 3.10 is the float 3.1 in any YAML.[a.b.c] headers and [[arrays.of.tables]].db = ${defaults.db} { pool-size = 20, pool-size = ${?DB_POOL_SIZE} }, and resolving throws BugOrBroken - an open issue since 2021. A separate db.pool-size = ${?DB_POOL_SIZE} line, like db.password above, works fine.For application config on the JVM, HOCON is my default: JSON-compatible, readable, composable and environment-friendly. YAML stays where the ecosystem forces it - Kubernetes manifests ;).