Skip to main content
Version: 2.0.x

Tutorial: How to Make a ZIO Application Configurable?

Introduction

One of the most common requirements for writing an application is to be able to configure it, especially when we are writing cloud-native applications.

In this tutorial, we will start with a simple ZIO application and then try to make it configurable using the ZIO Config library.

Prerequisites

We will use the ZIO Quickstart: Restful Web Service as our ground project. So make sure you have downloaded and tested it before you start this tutorial.

Problem

We have a web service that does not allow us to configure the host and port of the service:

git clone git@github.com:khajavi/zio-quickstart-restful-webservice.git
cd zio-quickstart-restful-webservice
sbt run

The output is:

Server started on http://localhost:8080

We want to be able to configure the host and port of the service so that before running the application, we specify the host and port of the service.

In this article, we will see how we can make our application configurable using ZIO Config.

Step 1: Define the Configuration Data Types (ADTs)

In this example our configuration data type is a case class that contains two fields:

case class HttpServerConfig(host: String, port: Int, nThreads: Int)

Step 2: Define the Configuration Descriptor

Next, we need to define the configuration descriptor that describes the configuration data type. The best practice is to define the configuration descriptor in the companion object of the configuration data type:

import zio.config._
import zio.Config
import zio.config.magnolia.deriveConfig

object HttpServerConfig {
val config: Config[HttpServerConfig] =
deriveConfig[HttpServerConfig].nested("HttpServerConfig")
}

Step 3: Accessing Configuration Data using ZIO.config

By utilizing the ZIO.config[HttpServerConfig] function, we can obtain access to the configuration information that has been read by the current ConfigProvider:

import zio._

ZIO.config[HttpServerConfig](HttpServerConfig.config).flatMap { config =>
??? // Do something with the configuration
}

The above code is a ZIO effect that will access the HttpServerConfig configuration data and then by using flatMap, we can do something with it, for example, we can print it:

import zio._

import java.io.IOException

val workflow: ZIO[Any, Exception, Unit] =
ZIO.config[HttpServerConfig](HttpServerConfig.config).flatMap { config =>
Console.printLine(
"Application started with following configuration:\n" +
s"\thost: ${config.host}\n" +
s"\tport: ${config.port}"
)
}

Let's run the above workflow and see the output:

import zio._

import java.io.IOException

case class HttpServerConfig(host: String, port: Int)

object MainApp extends ZIOAppDefault {

val workflow: ZIO[Any, IOException, Unit] =
ZIO.service[HttpServerConfig](HttpServerConfig.config).flatMap { config =>
Console.printLine(
"Application started with following configuration:\n" +
s"\thost: ${config.host}\n" +
s"\tport: ${config.port}"
)
}

def run = workflow
}
// error: not enough arguments for method service: (implicit evidence$8: zio.Tag[MdocApp0.this.HttpServerConfig], trace: zio.Trace): zio.URIO[MdocApp0.this.HttpServerConfig,MdocApp0.this.HttpServerConfig].
// Unspecified value parameter trace.
// ZIO.service[HttpServerConfig](HttpServerConfig.config).flatMap { config =>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

When try to run the above code, we will see the following output:

timestamp=2023-04-01T14:00:13.902065Z level=ERROR thread=#zio-fiber-0 message="" cause="Exception in thread "zio-fiber-4" zio.Config$Error$And: ((((Missing data at HttpServerConfig.host: Expected HTTPSERVERCONFIG_HOST to be set in the environment) or (Missing data at HttpServerConfig.host: Expected HttpServerConfig.host to be set in properties)) and ((Missing data at HttpServerConfig.nThreads: Expected HTTPSERVERCONFIG_NTHREADS to be set in the environment) or (Missing data at HttpServerConfig.nThreads: Expected HttpServerConfig.nThreads to be set in properties))) and ((Missing data at HttpServerConfig.port: Expected HTTPSERVERCONFIG_PORT to be set in the environment) or (Missing data at HttpServerConfig.port: Expected HttpServerConfig.port to be set in properties)))
at dev.zio.quickstart.MainApp.run(MainApp.scala:35)"

The above error is because we have not provided any configuration to the application. By default, ZIO will try to read configuration data from the application properties or environment variables.

So let's try to provide them as environment variables and see what happens:

HTTPSERVERCONFIG_HOST=localhost HTTPSERVERCONFIG_PORT=8080 HTTPSERVERCONFIG_NTHREADS=0 sbt "runMain dev.zio.quickstart.MainApp"

Now we can see this output:

Application started with following configuration:
host: localhost
port: 8080

Great! We have ZIO application that can access the configuration data. It works! Now, let's apply the same approach to our RESTful Web Service.

import zio._
import zio.http._
import zio.config.magnolia.deriveConfig

object GreetingApp {
def apply() = Http.empty
}

object DownloadApp {
def apply() = Http.empty
}

object CounterApp {
def apply() = Http.empty
}

object UserApp {
def apply() = Http.empty
}

trait UserRepo

object InmemoryUserRepo {
val layer = ZLayer.succeed(new UserRepo{})
}

case class HttpServerConfig(host: String, port: Int, nThreads: Int)

object HttpServerConfig {
val config: Config[HttpServerConfig] =
deriveConfig[HttpServerConfig].nested("HttpServerConfig")
}
import zio._
import zio.http._

import java.net.InetSocketAddress

object MainApp extends ZIOAppDefault {
val serverConfig: ZLayer[Any, Config.Error, ServerConfig] =
ZLayer
.fromZIO(
ZIO.config[HttpServerConfig](HttpServerConfig.config).map { c =>
ServerConfig(
address = new InetSocketAddress(c.port),
nThreads = c.nThreads
)
}
)

val myApp: Http[UserRepo with Ref[Int], Nothing, Request, Response] =
GreetingApp() ++ DownloadApp() ++ CounterApp() ++ UserApp()

def run =
Server
.serve(myApp)
.provide(
// Http server layer with its configuration
serverConfig,
Server.live,

// A layer responsible for storing the state of the `counterApp`
ZLayer.fromZIO(Ref.make(0)),

// To use the persistence layer, provide the `PersistentUserRepo.layer` layer instead
InmemoryUserRepo.layer,
)
}

Until now, we made our RESTful web service configurable to be able to use its config from the ZIO environment with a simple configuration layer.

Now let's move on to the next step: reading configuration data from HOCON files by utilizing custom ConfigProviders.

Step 3: Reading Configuration Data From HOCON Files

ZIO Config library provides various ways read configuration data from different sources, e.g.:

  • HOCON files
  • JSON files
  • YAML files
  • XML files

In this tutorial, we will use the HOCON files. HOCON is config format which is superset of JSON developed by Lightbend.

Adding ZIO Config Dependencies

We should add the following dependencies to our build.sb file:

libraryDependencies += "dev.zio" %% "zio-config"          % "4.0.0-RC14"
libraryDependencies += "dev.zio" %% "zio-config-typesafe" % "4.0.0-RC14"
libraryDependencies += "dev.zio" %% "zio-config-magnolia" % "4.0.0-RC14"

Defining the HOCON Configuration File

We can define our configuration inside application.conf file in the resources directory:

# application.conf

HttpServerConfig {
# The port to listen on.
port = 8080
port = ${?PORT}

# The hostname to listen on.
host = "localhost"
host = ${?HOST}

nThreads = 0
nThreads = ${?N_THREADS}
}

HOCON supports substitutions, so in the above configuration, we can use the environment variables ?PORT and ?HOST to substitute the values. We also provide a default value for the port and host.

Changing the Default ConfigProvider to HOCON Provider

To be able to read the configuration data from the HOCON files, we can use the TypesafeConfigProvider to read the configuration data from the application.conf file:

import zio._
import zio.config.typesafe.TypesafeConfigProvider

Runtime.setConfigProvider(
TypesafeConfigProvider.fromResourcePath()
)

Then we should change the default ConfigProvider to the new one by using Runtime.setConfigProvider layer:

import zio._
import zio.config.typesafe.TypesafeConfigProvider
import zio.http._

import java.net.InetSocketAddress

object MainApp extends ZIOAppDefault {
override val bootstrap: ZLayer[ZIOAppArgs, Any, Any] =
Runtime.setConfigProvider(
TypesafeConfigProvider
.fromResourcePath()
)

val myApp: Http[UserRepo with Ref[Int], Nothing, Request, Response] =
GreetingApp() ++ DownloadApp() ++ CounterApp() ++ UserApp()

val serverConfig: ZLayer[Any, Config.Error, ServerConfig] =
ZLayer
.fromZIO(
ZIO.config[HttpServerConfig](HttpServerConfig.config).map { c =>
ServerConfig(
address = new InetSocketAddress(c.port),
nThreads = c.nThreads
)
}
)

def run =
(Server
.install(myApp)
.flatMap(port =>
Console.printLine(s"Started server on port: $port")
) *> ZIO.never)
.provide(
serverConfig,
Server.live,

// A layer responsible for storing the state of the `counterApp`
ZLayer.fromZIO(Ref.make(0)),

// To use the persistence layer, provide the `PersistentUserRepo.layer` layer instead
InmemoryUserRepo.layer
)
}

Step 4: Running The Application

Now, if we run the application, it will start the server using the configuration defined in the application.conf file with its default values:

$ sbt run
Server started on port: 8080

We can set the HOST and PORT environment variables to override the default values:

$ HOST=localhost PORT=8081 sbt run
Server started on port: 8081

Conclusion

This tutorial covered how to use ZIO Config to read configuration data from HOCON files and configure our application. We haven't covered all the features of the ZIO Config library. To learn more about this library please visit the ZIO Config documentation.

The complete working example of this tutorial is available on the configurable-app branch of our ZIO Quickstart: Building RESTful Web Service quickstart on GitHub.