Functional dependency injection in Scala using ZIO environments
ZIO is a type-safe, composable library for asynchronous and concurrent programming in Scala (from: The ZIO github). The library copes with functional IO, like many Functional Programming libraries do. The added value of ZIO is that the ZIO[R, E, A]
type-constructor
(the main IO monad of the library) acts as an IO monad, an error handling monad, and a reader monad. A functional programming style often needs a combination of these three types to cope with the most common problems when creating an application:
-
performing side effects (getting the
A
) -
coping with errors (handling
E
) -
supplying dependencies (providing
R
)
This blogpost will show you how to cope with the R
part of a ZIO[R, E, A]
: the Environment
ZIO provided Environment
A very simple application is shown here. It just prints something to the console when it runs. To print to the console, ZIO provides the Console
environment.
Important here is that Main extends zio.App
. App provides a DefaultRuntime
, which contains a Console
for our Main to use. It also provides the signature of the main function which is started on boot:
def run(args: List[String]): ZIO[ZEnv, Nothing, Int]
Nice! This means we do not need to provide any other dependencies to our application:
import zio._
import zio.console.Console
object Main extends App {
override def run(args: List[String]): ZIO[Console, Nothing, Int] = program
val logic = (for {
_ <- console.putStrLn(s"I'm running!")
} yield 0)
.catchAll(e => console.putStrLn(s"Application run failed $e").as(1))
private val program = logic
}
A custom Environment
Sometimes, though, we would like to provide a custom dependency to our application.
To keep it very, very general for now, let’s provide our runtime with an X
! X can be anything of course, but providing an X
will show the general scaffold of an Environment
, or a Module
.
import zio._
trait XModule {
val xModule: XModule.Service[Any]
}
object XModule {
case class X(x: String = "x", y: String = "y", z: String = "z")
trait Service[R] {
def x: ZIO[R, Nothing, X]
}
trait Live extends XModule {
val xInstance: X
val xModule: XModule.Service[Any] = new Service[Any] {
override def x: ZIO[Any, Nothing, X] = UIO(xInstance)
}
}
object factory extends XModule.Service[XModule] {
override def x: ZIO[XModule, Nothing, X] = ZIO.environment[XModule].flatMap(_.xModule.x)
}
}
To give shape to X
, we made it a case class. For convenience, we created an accessor
called factory
to hide the logic of accessing the Environment
and getting to x
.
An instance of X can be retrieved like this:
val moduleX: XModule = ???
val x: UIO[X] = moduleX.xModule.x
We wrapped the instance in a UIO
, which is a ZIO[Any, Nothing, A]
(indicating it can run on any environment, and can never fail). Later we see why.
Let’s provide our program with an XModule
on top of the Console
, and use it to get an X
:
import com.jdriven.cvdsteeg.zio.XModule.X
import zio._
import zio.console.Console
object Main extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = program
val logic: ZIO[Console with XModule, Nothing, Int] = (for {
_ <- console.putStrLn(s"I'm running!")
x <- XModule.factory.x
_ <- console.putStrLn(s"I've got an $x!")
} yield 0)
.catchAll(e => console.putStrLn(s"Application run failed $e").as(1))
private val program = logic
.provideSome[Console] { c =>
new Console with XModule.Live {
override val console: Console.Service[Any] = c.console
override val xInstance: X = X()
}
}
}
To make the code compile, we need to provide
the ZIO[R, E, A]
that is returned by logic
with our custom module, because zio.App
does not provide us with an XModule
.
Therefore, we can use provideSome
to provide the parts of the R
that are not already provided, but are needed to run the ZIO[R, E ,A]
. If we can create an Environment that is a complete R
, we can use provide
here.
In short, provideSome(XModule)
can reduce a ZIO[Console with XModule, Nothing, Int]
to just a ZIO[Console, Nothing, Int]
Now our application can use an X! It doesn’t matter what instance of the module we provide
, as long as it has the functionality of the XModule
trait.
Therefore, the recommended way of creating a custom environment is by creating a trait.
Here it is also shown why it is convenient to let a module function return a ZIO
, it can be incorporated into the for-comprehension we’re already using.
We ask ZIO for an environment
of a certain type (XModule), and we can use it.
A useful example
We’ve seen the generic way of creating a module, let’s apply this structure to a more useful example: a module that will parse our application configuration for us.
Since the actual implementation doesn’t really matter, we’re going to let PureConfig
, a widely used functional config parser library, parse the default application.conf
file into our custom case class.
The config file looks like this:
app-name = "zio-environment-example"
PureConfig will parse this by default into a case class with a field appName: String
.
case class Configuration(appName: String)
The full ConfigurationModule
is shown here:
import pureconfig._
import pureconfig.generic.auto._
import zio._
trait ConfigurationModule {
val configurationModule: ConfigurationModule.Service[Any]
}
object ConfigurationModule {
case class ConfigurationError(message: String) extends RuntimeException(message)
case class Configuration(appName: String)
trait Service[R] {
def configuration: ZIO[R, Throwable, Configuration]
}
trait Live extends ConfigurationModule {
val configurationModule: ConfigurationModule.Service[Any] = new Service[Any] {
override def configuration: Task[Configuration] =
ZIO
.fromEither(ConfigSource.default.load[Configuration])
.mapError(e => ConfigurationError(e.toList.mkString(", ")))
}
}
object factory extends ConfigurationModule.Service[ConfigurationModule] {
override def configuration: ZIO[ConfigurationModule, Throwable, Configuration] = ZIO.accessM[ConfigurationModule](_.configurationModule.configuration)
}
}
PureConfig is a purely functional config parser, so it nicely wraps its result in an Either[ConfigFailures, Configuration]
, and we use ZIO.fromEither
to create a ZIO from this result.
Since we do not want to pass third-party errors around our application, we mapError
the PureConfig error to our own ConfigurationError
.
Note that, in contrary to the XModule
, we do not need to provide an instance
: the configuration is directly parsed from the application.conf
file on the classpath.
Also, in the accessor
we do not have to retrieve the full Environment
from ZIO, we can access
it and and provide a mapping to get directly to the Configuration we need, so convenient!
Let’s provide our Main with the ConfigurationModule:
import com.jdriven.cvdsteeg.zio.XModule.X
import zio._
import zio.console.Console
object Main extends App {
def run(args: List[String]): ZIO[ZEnv, Nothing, Int] = program
val logic: ZIO[Console with ConfigurationModule with XModule, Nothing, Int] = (for {
_ <- console.putStrLn(s"I'm running!")
x <- XModule.factory.x
_ <- console.putStrLn(s"I've got an $x!")
config <- ConfigurationModule.factory.configuration
_ <- console.putStrLn(s"This is the ${config.appName} application")
} yield 0)
.catchAll(e => console.putStrLn(s"Application run failed $e").as(1))
private val program = logic
.provideSome[Console] { c =>
new Console with XModule.Live with ConfigurationModule.Live {
override val console: Console.Service[Any] = c.console
override val xInstance: X = X()
}
}
}
To do this, we again add the ConfigurationModule dependency to the custom dependencies we provide
to our logic
, and use the factory
to access the Configuration
.
To get you started creating your own custom environments, take a look at the examples in the source code that can be found on GitLab.
Also, I would like to thank Pierangelo Cecchetto for sparking my interest in this topic and his feedback on this post.