Skip to Content
Course content

244: Environment-Specific Configuration Patterns

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I once worked with a developer who spent an entire Friday afternoon debugging a "ghost" issue where the application was intermittently failing to send emails in the staging environment. After three hours of digging through logs and questioning the network team, we discovered the culprit: he had accidentally committed a local application.conf file that pointed the SMTP server to localhost:1025 (his local MailHog instance) instead of the staging relay. It was a classic case of "it works on my machine" colliding violently with a shared environment.

In any professional Scala project, you cannot rely on manually editing config files before a deployment. You need a pattern that is deterministic, type-safe, and capable of overriding values based on where the code is actually running. Most of us in the ecosystem lean on HOCON (Human-Optimized Config Object Notation) via the Lightbend Config library, but the real magic happens in how you layer those files and map them to your code.

Layering Configurations with HOCON

The most effective way to manage environment drift is through a layering strategy. Instead of one giant file for every environment, you create a base application.conf containing defaults that apply to everyone, and then create environment-specific overrides like prod.conf or staging.conf.

In HOCON, you can use the include statement to pull in the base config. For example, your prod.conf might look like this:

include "application.conf"

db {
  url = "jdbc:postgresql://prod-db.cluster.internal:5432/myapp"
  poolSize = 20
}

When you launch your application, you tell the JVM which config file to use by passing a system property: -Dconfig.file=prod.conf. This keeps your production secrets and infrastructure details out of the main application logic and allows you to ship the same JAR file to every environment, changing only the startup flag.

Bridging HOCON and Environment Variables

While files are great for structural configuration, they are terrible for secrets. You should never, ever commit a production API key to Git. This is where environment variables come in. HOCON has a built-in syntax for this: ${?VARIABLE_NAME}. The question mark is the key here—it tells the library "use this environment variable if it exists, otherwise keep the default value."

I usually set up my configs like this:

api {
  key = "dev-key-123" # Default for local dev
  key = ${?STRIPE_API_KEY} # Override if the env var is set
  timeout = 5s
}

This pattern is a lifesaver. Your local setup just works out of the box, but your Kubernetes pod or Heroku dyno can inject the real secret via the environment without you having to change a single line of code.

Lifting Config into Type-Safe Case Classes

Using config.getString("api.key") all over your codebase is a recipe for runtime ConfigException.Missing crashes. It's "string-ly typed" programming, and we can do better in Scala. The gold standard is to load your configuration into a case class exactly once at application startup.

While you can write the boilerplate yourself, libraries like PureConfig make this seamless. By defining a case class that mirrors your config structure, you move the failure point to the very start of the application. If a required config value is missing or is an Integer when it should be a Boolean, the app will crash immediately on startup rather than failing three hours later when a specific code path is hit.

case class DbConfig(url: String, poolSize: Int)
case class AppConfig(db: DbConfig, apiTimeout: FiniteDuration)

// PureConfig loading example
import pureconfig._
import pureconfig.generic.auto._

val config = ConfigSource.default.load[AppConfig]

By treating your configuration as data rather than a lookup table, you get autocomplete, compiler checks, and a clear contract of what your application actually needs to run.




📋 Practical Task

Implement a Multi-Environment Payment Gateway Loader

You are building a payment integration that must behave differently in Development and Production. Your task is to implement a configuration loader that prevents the app from starting if critical production keys are missing, while allowing defaults for development.

Requirements:

  • Define a case class PaymentConfig containing: apiKey: String, endpoint: String, and retryCount: Int.
  • Create a base application.conf with local defaults (e.g., endpoint = "http://localhost:8080", retryCount = 3).
  • Implement a loading mechanism that:
    • Checks for an environment variable PAYMENT_API_KEY.
    • If the system property -Denv=prod is set, the application must throw an exception if PAYMENT_API_KEY is missing or empty.
    • If -Denv=prod is NOT set, it should fall back to a dummy "dev-key".
  • Ensure the retryCount is loaded as an Integer from the config file.

Deliverable: Provide the PaymentConfig case class and the Scala object/method used to load and validate the configuration based on the environment flag.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.