Introduction to Configuration in ZIO
Configuration is a core concern for any cloud-native application. So ZIO ships with built-in support for configuration by providing a front-end for configuration providers as well as metrics and logging.
So, ZIO provides a unified way to configure our applications, while still enabling customizability, flexibility, and significant integrations with configuration backends via ecosystem projects, most notably ZIO Config.
This configuration front-end allows ecosystem libraries and applications to declaratively describe their configuration needs and delegates the heavy lifting to a ConfigProvider, which may be supplied by third-party libraries such as ZIO Config.
The ZIO Core ships with a simple default config provider, which reads configuration data from environment variables and if not found, from system properties. This can be used for development purposes or to bootstrap applications toward more sophisticated config providers.
Getting Started
To make our application configurable, we should know about three essential elements:
Config Description— To describe configuration data of type
A
, we should create an instance ofConfig[A]
. If the configuration data is simple (such asstring
,string
,boolean
), we can use built-in configs inside companion object ofConfig
data type. By combining primitive configs, we can model custom data types such asHostPort
.Config Front-end— By using
ZIO.config
we can load configuration data described byConfig
. It takes aConfig[A]
instance or expect implicitConfig[A]
and loads the config using the currentConfigProvider
.Config Backend—
ConfigProvider
is the underlying engine thatZIO.config
uses to load configs. ZIO has a default config provider inside its default services. The default config provider reads configuration data from environment variables and if not found, from system properties. To change the default config provider, we can useRuntime.setConfigProvider
layer to configure the ZIO runtime to use a custom config provider.
By introducing built-in config front-end in ZIO Core, the old way of reading configuration data using ZLayer
is deprecated, and we don't recommend using layers for configuration anymore.
Primitive Configs
ZIO provides a set of primitive configs for the most common types like int
, long
, string
, boolean
, double
, etc. All of these configs are available inside the Config
object.
Let's start with a simple example of how to read configuration from environment variables and system properties:
import zio._
object MainApp extends ZIOAppDefault {
def run = {
for {
host <- ZIO.config(Config.string("host"))
port <- ZIO.config(Config.int("port"))
_ <- Console.printLine(s"Application started: $host:$port")
} yield ()
}
}
Use ZIO.config(config: A)
overload for primitive data types instead of ZIO.config[A]
to avoid potential implicit conflicts.
If we run this application we will get the following output:
timestamp=2023-02-14T09:45:27.074151Z level=ERROR thread=#zio-fiber-0 message="" cause="Exception in thread "zio-fiber-4" zio.Config$Error$Or: ((Missing data at host: Expected HOST to be set in the environment) or (Missing data at host: Expected host to be set in properties))
This is because we have not provided any configuration. Let's try to run it with the following environment variables:
HOST=localhost PORT=8080 sbt "runMain MainApp"
Now we get the following output:
Application started: localhost:8080
We can also run it by setting system properties:
sbt -Dhost=localhost -Dport=8080 "runMain MainApp"
Custom Configs
Other than primitive types, we can also define a configuration for custom types. To do so, we need to use primitive configs and combine them together using Config
operators (++
, ||
, map
, etc) and constructors (listOf
, chunkOf
, setOf
, vectorOf
, table
, etc).
Example 1
Let's say we have the HostPort
data type, which consists of two fields: host
and port
:
case class HostPort(host: String, port: Int)
We can define implicit config for this data type by combining primitive string
and int
configs:
import zio._
object HostPort {
implicit val config: Config[HostPort] =
(Config.string("host") ++ Config.int("port")).map { case (host, port) =>
HostPort(host, port)
}
}
The best practice is to put the implicit Config
value in the companion object of the configuration data type and call it config
.
If we use this customized config in our application, it tries to read corresponding values from environment variables (HOST
and PORT
) and system properties (host
and port
):
for {
config <- ZIO.config[HostPort]
_ <- Console.printLine(s"Application started: $config")
} yield ()
Example 2
Now let's assume we want to have multiple HostPort
configurations. We can define a config for a list of HostPort
like bellow using the listOf
constructor:
case class HostPorts(hostPorts: List[HostPort])
object HostPorts {
implicit val config: Config[HostPorts] =
Config.listOf(HostPort.config).map(HostPorts(_))
}
Then we can use this config in our application:
for {
config <- ZIO.config[HostPorts]
_ <- Console.printLine(s"Application started with:")
_ <- ZIO.foreachDiscard(config.hostPorts)(e => Console.printLine(s" - http://${e.host}:${e.port}"))
} yield ()
With the default config provider, we can run the application with the following environment variables:
HOST=host1,host2,host3 PORT=8080,8081,8082 sbt "runMain MainApp"
The output will be:
Application started with:
- http://host1:8081
- http://host2:8082
- http://host3:8083
Top-level and Nested Configs
So far we have seen how to define configuration in a top-level manner, whether it is a primitive or a custom type. But we can also define a nested configuration.
Assume we have a SerivceConfig
data type that consists of two fields: hostPort
and timeout
:
case class ServiceConfig(hostPort: HostPort, timeout: Int)
Let's define a config for this type in its companion object:
import zio._
object ServiceConfig {
implicit val config: Config[ServiceConfig] =
(HostPort.config ++ Config.int("timeout")).map {
case (a, b) => ServiceConfig(a, b)
}
}
If we use this customized config in our application, it tries to read corresponding values from environment variables (HOST
, PORT
, and TIMEOUT
) and, if not found from system properties (host
, port
, and timeout
).
But in most circumstances, we don't want to read all the configurations from the top-level namespace. Instead, we want to nest them under a common namespace. In this case, we want to read both HOST
and PORT
from the HOSTPORT
namespace, and TIMEOUT
from the root namespace. In order to do that, we can use the nested
combinator:
object ServiceConfig {
implicit val config: Config[ServiceConfig] =
- (HostPort.config ++ Config.int("timeout")).map {
+ (HostPort.config.nested("hostport") ++ Config.int("timeout")).map {
case (a, b) => ServiceConfig(a, b)
}
}
Now, if we run our application, it tries to read corresponding values from environment variables (HOSTPORT_HOST
, HOSTPORT_PORT
and TIMEOUT
) and, if not found it tries to read from system properties (hostport.host
, hostport.port
and timeout
).
Built-in Config Providers
ZIO has some built-in config providers:
ConfigProvider.defaultProvider
- reads configuration from environment variables and if not found, from system propertiesConfigProvider.envProvider
- reads configuration from environment variablesConfigProvider.propsProvider
- reads configuration from system propertiesConfigProvider.consoleProvider
- reads configuration from interactive console prompts
Other than these built-in providers, we can also use third-party providers in ZIO ecosystem libraries, such as ZIO Config which provides a rich set of backends for reading configuration from different sources such as HOCON, JSON, YAML, etc.
Custom Config Provider
We can also define our own custom config providers.
The default config provider is used by default, but we can also override it by using Runtime#setConfigProvider
.
In the following example, we set the default config provider to consoleProvider
which reads configuration from the console:
import zio._
object MainAppScoped extends ZIOAppDefault {
override val bootstrap: ZLayer[Any, Nothing, Unit] =
Runtime.setConfigProvider(ConfigProvider.consoleProvider)
def run =
for {
host <- ZIO.config(Config.string("host"))
port <- ZIO.config(Config.int("port"))
_ <- Console.printLine(s"Application started: http://$host:$port")
} yield ()
}
The console provider is stored inside a FiberRef
, so we can override it in a scoped manner. This is useful for changing the config provider for a specific part of the application.
Testing Services
When testing services, we sometimes need to provide some configuration to them. So we should be able to mock any backend that we use for reading configuration data.
In order to do that, we can use the ConfigProvider.fromMap
constructor, which takes a map of configuration data and returns a config provider that reads configuration from that map. Then we can pass that to the Runtime.setConfigProvider
, which returns a ZLayer
that we can use to override the default config provider for our test specs using Spec#provideLayer
operator:
import zio._
import zio.test._
object MyServiceTest extends ZIOSpecDefault {
val mockConfigProvider: ZLayer[Any, Nothing, Unit] =
Runtime.setConfigProvider(ConfigProvider.fromMap(Map("timeout" -> "5s")))
// This service reads configuration data (host and port) inside its implementation
def myService: ZIO[Any, Config.Error, Double] = ???
override def spec = {
val expected: Double = ??? // expected value
test("test myService") {
for {
result <- myService
} yield assertTrue(result == expected)
}
}.provideLayer(mockConfigProvider)
}