1. Introduction

Configuration values are essential for most software programs.  However, this proves to be a tedious task since configurations are stored in many different forms, such as XML, JSON, YAML, and environment variables, to mention but a few. Fetching these values from these different formats can prove to be error-prone and time-consuming, especially if done manually. Thus, there is a need for a configuration loading library such as Ciris.

Ciris is a lightweight and composable configuration loading library for Scala. In this tutorial, we’ll explore important Ciris features and how they aim to ease configuration loading in a Scala project. There are many other configuration libraries for Scala, such as PureConfig, and the very popular Lightbend Config Java library, all of which have similar features to Ciris, such as support for JSON and HACON.

2. Setup

To follow along with this tutorial, we’ll need to add the following in our build.sbt file:

val scala3Version = "3.4.0"

lazy val root = project
  .in(file("."))
  .settings(
    name := "cirisproject",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3Version,
    libraryDependencies += "is.cir" %% "ciris" % "3.5.0",
    libraryDependencies += "org.typelevel" %% "cats-effect" % "3.5.4",
    libraryDependencies += "is.cir" %% "ciris-circe" % "3.5.0",
    libraryDependencies += "is.cir" %% "ciris-circe-yaml" % "3.5.0",
    libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % Test
  )

Here, we add ciris, cats-effect, one of its dependencies, and lastly, ciris-circe and ciris-circe-yaml.

3. Loading From Environmental Variables

In this section, we’ll look at a situation where we need to load the username and password configuration values required to connect to a Postgres database:

object configuration:
  final case class PostgresConfig(username: String, password: String)

Above, we created PostgresConfig, a case class to hold the username and password, both of type String.

To fetch configuration values stored as environment variables, we use the env() function provided by Ciris that can access these values by their key:

object configuration:
  ...
  def postgresConfig: ConfigValue[Effect, PostgresConfig] =
    (
      env("POSTGRES_USERNAME").as[String],
      env("POSTGRES_PASSWORD").as[String]
    ).parMapN(PostgresConfig.apply)

The env() function takes the key and returns the associated value as a String by calling as[String] on the result. Finally, we call parMapN() and pass the associated values of POSTGRES_USERNAME and POSTGRES_PASSWORD, respectively, to PostgresConfig.apply.

This produces a ConfigValue[Effect, PostgresConfig], which holds the configuration value as PostgresConfig along with Effect, which represents any effect type:

object program extends IOApp.Simple:
  import configuration.*

  override def run: IO[Unit] = 
    postgresConfig.load[IO].map(println).void

Now we can access our configuration values by calling load[IO] on postgresConfig passing the effect type as IO, this produces an IO[PostgresConfig].
When we run our program, we get the following results:

[error] ciris.ConfigException: configuration loading failed with the following errors.
[error] 
[error]   - Missing environment variable POSTGRES_USERNAME.
[error]   - Missing environment variable POSTGRES_PASSWORD.

Our program blows up since we haven’t yet created the environment variables. However, we can guard against such errors using the Option type.

4. Managing Errors

To manage errors, we’ll modify PostgresConfig to include the Option type:

object configuration:
  ...
  final case class PostgresConfig2(username: Option[String], password: Option[String])

  def postgresConfig2: ConfigValue[Effect, PostgresConfig2] = 
    (
      env("POSTGRES_USERNAME").as[String].option,
      env("POSTGRES_PASSWORD").as[String].option
    ).parMapN(PostgresConfig2.apply)

We now use Option[String] in PostgresConfig2 and also add the option method to env(). Now, when we run our code, it doesn’t blow up:

object program extends IOApp.Simple:
    ...
    override def run: IO[Unit] = 
        postgresConfig2.load[IO].map(println).void

This returns PostgresConnfig2(None,None), now we can create our environmental variables using the following bash commands:

$ export POSTGRES_USERNAME=username
$ export POSTGRES_PASSWORD=password

If we run our program again, it should return PostgresConfig2(Some(username), Some(password)).

object program extends IOApp.Simple:
    ...
    override def run: IO[Unit] = 
        postgresConfig2.load[IO].map(println).void

5. Adding Proper Types

In larger applications, it’s essential to use proper types. In the following example, we’ll replace String with Username and Password case classes as follows:

object configuration:
  ...
  case class Username(name: String)
  case class Password(value: String)

  final case class PostgresConfig3(username: Option[Username], password: Option[Password])
  
  def postgresConfig3: ConfigValue[Effect, PostgresConfig3] = 
    (
      env("POSTGRES_USERNAME").as[Username].option,
      env("POSTGRES_PASSWORD").as[Password].option
    ).parMapN(PostgresConfig3.apply)

When we replace all the String instances with Username and Password, we encounter the following error, “No given instance of type ciris.ConfigDecoder”, therefore, we need to provide a ConfigDecoder to convert the values received from env() to Username and Password respectively.

This can be achieved by providing the given instances for CofigDecoder on the respective companion objects for Username and Password:

object configuration:
  ...
  case class Username(name: String)
  object Username:
    given ConfigDecoder[String, Username] = 
      ConfigDecoder[String, String].map(Username.apply)

  case class Password(value: String)
  object Password:
    given ConfigDecoder[String, Password] = 
      ConfigDecoder[String, String].map(Password.apply)
  ...

We now provide a ConfigDecoder[String, String] and map the received value to Username.apply and Password.apply. If we run our program, it should return PostgresConfig3(Some(Username(username)), Some(Password(password))):

object program extends IOApp.Simple:
  ...
  override def run: IO[Unit] = 
    postgresConfig3.load[IO].map(println).void

6. Loading Configuration Files

In this section, we’ll look at acquiring configuration values from JSON and YAML files.

6.1. Loading From JSON File

We start by creating a JSON file in the following path, src/main/resources/postgresConfig.json, and add the following content:

{
    "username": "username",
    "password": "password"
}

It contains two keys, username, and password, that point to the username and password values:

object configuration:
  ...
  final case class PostgresConfig4(username: Username, password: Password)
  val postgresConfig4: ConfigValue[Effect, PostgresConfig4] = 
    file(
      Path.of("src/main/resources/postgresConfig.json")
    ).as[PostgresConfig4]

In this example, we use the file() method provided by Ciris, which takes a Path from java.nio. The result is finally cast to PostgresConfig4 using the as() method.

Notice that we created PostgresConfig4 without the Option type. This is because error handling in this scenario will be handled differently. More on this later.

To compile our code, we require a given instance of ConfigDecoder[String, PostgresConfig4]:

object configuration:
  ...
  object PostgresConfig4:
    given ConfigDecoder[String, PostgresConfig4] =
      circeConfigDecoder("PostgresConfig4")

We provide this instance by calling circeConfigDecoder() and passing it the type name PostgresConfig4. However, the circeConfigDecoder() also requires a given instance of Decoder[configuration.PostgresConfig4] in scope:

object configuration:
  ...
  object PostgresConfig4:
    given Decoder[PostgresConfig4] = Decoder.instance { h =>
      for
        username <- h.get[String]("username")
        password <- h.get[String]("password")
      yield PostgresConfig4(Username(username), Password(password))
    }

Thus, we use the Decoder.instance() method from Circe, from which we acquire the keys and pass them to PostgresConfig4. When we run our code we get PostgresConfig4(Username(username), Password(password)):

object program extends IOApp.Simple:
  ...
  override def run: IO[Unit] = 
    postgresConfig4.load[IO].map(println).void

To handle any errors that may occur while fetching value from the JSON file, we can use the attempt() method:

object configuration:
  ...
  def postgresConfig5[F[_]: Async]: F[Either[ConfigError, PostgresConfig4]] = 
    file(
      Path.of("src/main/resources/postgresConfig.json")
    ).as[PostgresConfig4].attempt[F]

Calling attempt() requires an implicit Async, so we bind F[_], our effect type to it, and also pass F as our effect to attempt(). This results in an F[Either[ConfigError, PostgresConfig4]] with the error channel as ConfigError. Finally, we can run our program as follows:

object program extends IOApp.Simple:
  ...
  override def run: IO[Unit] = 
    postgresConfig5[IO].map{config =>
      config match
        case Right(value) => println(value)
        case Left(err) => err.messages.map(println)
      } 

6.2. Loading From a YAML File

First, we’ll create a YAML file in the following path, src/main/resources/postgresConfig.yaml, and add the following code:

username: username
password: password

Just like the JSON file example, we’ll need a ConfigDecoder[String, PostgresConfig4] in scope:

object configuration:
  ...
  object PostgresConfig4:
    given ConfigDecoder[String, PostgresConfig4] =
      circeYamlConfigDecoder("PostgresConfig4") 

  ...
  def postgresConfig6[F[_]: Async]: F[Either[ConfigError, PostgresConfig4]] = 
    file(
      Path.of("src/main/resources/postgresConfig.yaml")
    ).as[PostgresConfig4].attempt[F]

Again, we have a circeYamlConfigDecoder() function passing it the type name to create our given. We also make sure to comment out the previously given JSON ConfigDecoder since they both can’t be in scope at the same time.

Lastly, we also use the file() function, passing it the Path to postgresConfig.yaml, and finally, when we run our function, we get PostgresConfig4(Username(username), Password(password)):

object program extends IOApp.Simple:
  ...
  override def run: IO[Unit] = 
    postgresConfig6[IO].map{config =>
      config match
        case Right(value) => println(value)
        case Left(err) => err.messages.map(println)
      }

7. Handling Secrets

Ciris provides a way of handling sensitive information by wrapping it in a Secret type, which only shows the first 7 characters of the SHA-1 hash of the value:

object configuration:
  ...
  case class Password2(value: Secret[String])
  object Password2:
    def apply(value: String) = 
      new Password2(Secret(value))

Above, we create Password2 that takes a Secret[String] as a value. In the apply() method, we wrap the required value in a Secret class and pass it to Password2. Secret also has the advantage of hiding sensitive information when printed as part of an error message:

object program extends IOApp.Simple:
  ...
  override def run: IO[Unit] = 
    IO(println(Password2(Secret("password"))))

When we run the program it returns Password2(Secret(5baa61e)), the Secret type can be applied to values from JSON or YAML files and environment variables.

8. Configuring Fallback Values

In this section, we’ll look at how to configure fallback values in Ciris:

object configuration:
  ...
  def postgresConfig7[F[_]: Async]: F[Either[ConfigError, PostgresConfig4]] = 
    file(
      Path.of("src/main/resources/missing.yaml")
    ).as[PostgresConfig4].default{
      PostgresConfig4(Username("username"), Password("password"))
    }.attempt[F]

We provide a Path to missing.yaml that doesn’t exist, then provide a default configuration using the default() method:

object program extends IOApp.Simple:
  ...
  override def run: IO[Unit] = 
    postgresConfig7[IO].map{config =>
      config match
        case Right(value) => println(value)
        case Left(err) => err.messages.map(println)
      }

Ciris will load the default configuration when it cannot find the file or environment variable. Note that defaults will be used if the value is missing.  Also, if the value is a composition of multiple values, the default will be used if all of them are missing.

Finally, when we run our function, we return PostgresConfig4(Username(username), Password(password)), which was defined in the default() function as a fallback to the missing configuration YAML file.

9. Conclusion

In this tutorial, we covered the important features of Ciris and learned how to implement them in a Scala project. Ciris comes with other modules to ease compatibility with Enumeratum, Http4s, Refined, and Squants. It also has many unofficial modules we didn’t cover in this article. We encourage you to explore these. As always, the code for this article can be found over on GitHub.