Scala - Configuration (Typesafe Config)
Typesafe Config (now Lightbend Config) is the de facto standard for configuration management in Scala applications. It reads configuration from multiple sources and merges them into a single unified...
Key Insights
- Typesafe Config provides a robust configuration management solution for Scala applications with support for HOCON format, environment variable overrides, and type-safe value extraction
- The library implements a layered configuration approach where application.conf, reference.conf, and system properties merge automatically following predictable precedence rules
- Configuration objects are immutable and thread-safe, making them ideal for concurrent applications while supporting validation patterns that fail fast at startup rather than runtime
Understanding Typesafe Config Fundamentals
Typesafe Config (now Lightbend Config) is the de facto standard for configuration management in Scala applications. It reads configuration from multiple sources and merges them into a single unified view using the HOCON (Human-Optimized Config Object Notation) format.
Add the dependency to your build.sbt:
libraryDependencies += "com.typesafe" % "config" % "1.4.3"
The simplest configuration starts with loading the default configuration:
import com.typesafe.config.{Config, ConfigFactory}
object BasicConfigExample extends App {
val config: Config = ConfigFactory.load()
val appName = config.getString("app.name")
val port = config.getInt("app.port")
val enabled = config.getBoolean("app.feature.enabled")
println(s"Application: $appName running on port $port")
}
Create src/main/resources/application.conf:
app {
name = "MyScalaApp"
port = 8080
feature {
enabled = true
}
}
Configuration Hierarchy and Merging
Typesafe Config follows a three-layer hierarchy: system properties override application.conf, which overrides reference.conf. Libraries should provide reference.conf with defaults, while applications use application.conf for deployment-specific values.
Create src/main/resources/reference.conf for library defaults:
database {
driver = "org.postgresql.Driver"
connection-timeout = 30s
pool {
min-size = 5
max-size = 20
}
}
http {
host = "0.0.0.0"
port = 8080
request-timeout = 60s
}
Override with src/main/resources/application.conf:
database {
url = "jdbc:postgresql://localhost:5432/mydb"
username = "dbuser"
password = "changeme"
pool {
max-size = 50 // Override only max-size
}
}
http {
port = 9000 // Override port
}
Access merged configuration:
object ConfigMergeExample extends App {
val config = ConfigFactory.load()
// From reference.conf
println(config.getString("database.driver"))
// org.postgresql.Driver
// From application.conf
println(config.getInt("database.pool.max-size"))
// 50
// Merged: min-size from reference, max-size from application
println(config.getInt("database.pool.min-size"))
// 5
}
Type-Safe Configuration with Case Classes
Manually extracting configuration values is error-prone. Create type-safe configuration objects that validate at startup:
import scala.concurrent.duration._
import scala.util.{Try, Success, Failure}
case class DatabaseConfig(
url: String,
username: String,
password: String,
driver: String,
connectionTimeout: Duration,
poolConfig: PoolConfig
)
case class PoolConfig(
minSize: Int,
maxSize: Int
)
case class HttpConfig(
host: String,
port: Int,
requestTimeout: Duration
)
case class AppConfig(
database: DatabaseConfig,
http: HttpConfig
)
object AppConfig {
def load(): Try[AppConfig] = Try {
val config = ConfigFactory.load()
val dbConfig = config.getConfig("database")
val poolConfig = dbConfig.getConfig("pool")
val database = DatabaseConfig(
url = dbConfig.getString("url"),
username = dbConfig.getString("username"),
password = dbConfig.getString("password"),
driver = dbConfig.getString("driver"),
connectionTimeout = Duration(dbConfig.getDuration("connection-timeout").toMillis, MILLISECONDS),
poolConfig = PoolConfig(
minSize = poolConfig.getInt("min-size"),
maxSize = poolConfig.getInt("max-size")
)
)
val httpConfig = config.getConfig("http")
val http = HttpConfig(
host = httpConfig.getString("host"),
port = httpConfig.getInt("port"),
requestTimeout = Duration(httpConfig.getDuration("request-timeout").toMillis, MILLISECONDS)
)
AppConfig(database, http)
}
}
object TypeSafeConfigApp extends App {
AppConfig.load() match {
case Success(config) =>
println(s"Database URL: ${config.database.url}")
println(s"HTTP Server: ${config.http.host}:${config.http.port}")
println(s"Pool size: ${config.database.poolConfig.minSize}-${config.database.poolConfig.maxSize}")
case Failure(exception) =>
System.err.println(s"Configuration error: ${exception.getMessage}")
System.exit(1)
}
}
Environment-Specific Configurations
Manage multiple environments using configuration substitution and environment variables:
# application.conf
env = "development"
env = ${?APP_ENV} // Override with APP_ENV environment variable
database {
url = "jdbc:postgresql://localhost:5432/mydb"
url = ${?DATABASE_URL}
username = "devuser"
username = ${?DB_USERNAME}
password = "devpass"
password = ${?DB_PASSWORD}
}
http {
port = 8080
port = ${?HTTP_PORT}
}
# Environment-specific overrides
development {
database {
pool.max-size = 10
}
}
production {
database {
pool.max-size = 100
connection-timeout = 10s
}
http {
request-timeout = 30s
}
}
Load environment-specific configuration:
object EnvironmentConfig extends App {
val config = ConfigFactory.load()
val env = config.getString("env")
// Fallback to root config if environment-specific config doesn't exist
val envConfig = if (config.hasPath(env)) {
config.getConfig(env).withFallback(config)
} else {
config
}
println(s"Environment: $env")
println(s"Database pool max: ${envConfig.getInt("database.pool.max-size")}")
println(s"HTTP port: ${envConfig.getInt("http.port")}")
}
Run with environment variables:
APP_ENV=production DATABASE_URL=jdbc:postgresql://prod-db:5432/prod sbt run
Advanced Configuration Patterns
Handle optional values, lists, and complex types:
# application.conf
app {
name = "AdvancedApp"
# Optional feature flags
features = ["auth", "metrics", "caching"]
# Map-like structures
services {
user-service = "http://users:8080"
order-service = "http://orders:8081"
payment-service = "http://payments:8082"
}
# Nested lists
retry {
max-attempts = 3
backoff-intervals = [1s, 5s, 10s]
}
}
Extract complex configuration:
import scala.jdk.CollectionConverters._
case class RetryConfig(
maxAttempts: Int,
backoffIntervals: List[Duration]
)
object AdvancedConfigExample extends App {
val config = ConfigFactory.load().getConfig("app")
// Extract lists
val features: List[String] = config
.getStringList("features")
.asScala
.toList
println(s"Enabled features: ${features.mkString(", ")}")
// Extract map-like config
val servicesConfig = config.getConfig("services")
val services: Map[String, String] = servicesConfig
.entrySet()
.asScala
.map(entry => entry.getKey -> servicesConfig.getString(entry.getKey))
.toMap
services.foreach { case (name, url) =>
println(s"Service $name: $url")
}
// Extract nested lists with durations
val retryConfig = config.getConfig("retry")
val retry = RetryConfig(
maxAttempts = retryConfig.getInt("max-attempts"),
backoffIntervals = retryConfig
.getDurationList("backoff-intervals")
.asScala
.map(d => Duration(d.toMillis, MILLISECONDS))
.toList
)
println(s"Retry: ${retry.maxAttempts} attempts with intervals ${retry.backoffIntervals}")
}
Configuration Validation
Implement validation to catch configuration errors at startup:
sealed trait ConfigError
case class MissingKey(key: String) extends ConfigError
case class InvalidValue(key: String, reason: String) extends ConfigError
case class ValidationError(errors: List[ConfigError]) extends ConfigError
object ConfigValidator {
def validate(config: Config): Either[ValidationError, AppConfig] = {
val errors = scala.collection.mutable.ListBuffer[ConfigError]()
// Validate required keys
val requiredKeys = List(
"database.url",
"database.username",
"database.password",
"http.port"
)
requiredKeys.foreach { key =>
if (!config.hasPath(key)) {
errors += MissingKey(key)
}
}
// Validate port range
if (config.hasPath("http.port")) {
val port = config.getInt("http.port")
if (port < 1 || port > 65535) {
errors += InvalidValue("http.port", s"Port $port out of valid range (1-65535)")
}
}
// Validate pool sizes
if (config.hasPath("database.pool.min-size") && config.hasPath("database.pool.max-size")) {
val minSize = config.getInt("database.pool.min-size")
val maxSize = config.getInt("database.pool.max-size")
if (minSize > maxSize) {
errors += InvalidValue("database.pool", s"min-size ($minSize) cannot exceed max-size ($maxSize)")
}
}
if (errors.nonEmpty) {
Left(ValidationError(errors.toList))
} else {
AppConfig.load().toEither.left.map(_ => ValidationError(List()))
}
}
}
object ValidatedConfigApp extends App {
val config = ConfigFactory.load()
ConfigValidator.validate(config) match {
case Right(appConfig) =>
println("Configuration validated successfully")
// Start application with appConfig
case Left(ValidationError(errors)) =>
System.err.println("Configuration validation failed:")
errors.foreach {
case MissingKey(key) =>
System.err.println(s" - Missing required key: $key")
case InvalidValue(key, reason) =>
System.err.println(s" - Invalid value for $key: $reason")
}
System.exit(1)
}
}
This validation approach ensures your application fails fast with clear error messages rather than encountering runtime surprises from misconfiguration.