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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.