Skip to main content
Version: 2.0.x

Motivation

caution

In this section, we are going to study how ZIO supports dependency injection by providing pedagogical examples. Examples provided in these sections are not idiomatic and are not meant to be used as a reference. We will discuss the idiomatic way to use dependency injection in ZIO later.

So feel free to skip reading this section if you are not interested to learn the underlying concepts in detail.

Assume we have two services called Formatter and Compiler like the below:

import zio._

class Formatter {
def format(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

class Compiler {
def compile(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

We want to create an editor service, which uses these two services. Hence, we are going to instantiate the required services inside the Editor class:

class Editor {
private val formatter: Formatter = new Formatter()
private val compiler: Compiler = new Compiler()

def formatAndCompile(code: String): UIO[String] =
formatter.format(code).flatMap(compiler.compile)
}

There are some problems with this approach:

  1. Users of the Editor service haven't any control over how dependencies will be created.
  2. Users of the Editor service cannot use different implementations of Formatter and Compiler services. For example, we would like to test the Editor service with a mock version of Formatter and Compiler. With this approach, mocking these dependencies is hard.
  3. The Editor service is tightly coupled with Formatter and Compiler. This means any change to these services, may introduce a new change in the Editor class.
  4. Creating the object graph is a manual process.

Let's see how we can provide a solution to these problems. In the following sections, we will step by step solve these problems, and finally, we will see how ZIO solves the dependency injection problem.

Step 1: Inversion of Control

On solution to the first problem is inverting the control to the user of the Editor service, which is called Inversion of Control.

Now lets instead of instantiating the dependencies inside the Editor service, create them outside the Editor service and pass them to the Editor service:

class Editor(formatter: Formatter, compiler: Compiler) {
def formatAndCompile(code: String): UIO[String] =
formatter.format(code).flatMap(compiler.compile)
}

Now the Editor service is decoupled from how the Formatter and Compiler services are created. The client of the Editor service can instantiate the Formatter and Compiler services and pass them to the Editor service:

val formatter = new Formatter() // creating formatter
val compiler = new Compiler() // creating compiler
val editor = new Editor(formatter, compiler) // assembling formatter and compiler into editor

editor.formatAndCompile("println(\"Hello, world!\")")

Step 2: Decoupling from Implementations

In the previous step, we delegated the creation of dependencies to the client of the Editor service. This decouples the Editor service from the creation of the dependencies. But it is not enough. We still coupled to the concrete classes called Formatter and Compiler. The user of the Editor service cannot use different implementations rather than the Formatter and Compiler services. This is where the object-oriented approach comes into play. By programming to interfaces, we can encapsulate the Editor service and make it independent of concrete implementations:

trait Formatter {
def format(code: String): UIO[String]
}

class ScalaFormatter extends Formatter {
def format(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

trait Compiler {
def compile(code: String): UIO[String]
}

class ScalaCompiler extends Compiler {
def compile(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

trait Editor {
def formatAndCompile(code: String): UIO[String]
}

class EditorLive(formatter: Formatter, compiler: Compiler) extends Editor {
def formatAndCompile(code: String): UIO[String] =
formatter.format(code).flatMap(compiler.compile)
}

val formatter = new ScalaFormatter() // Creating Formatter
val compiler = new ScalaCompiler() // Creating Compiler
val editor = new EditorLive(formatter, compiler) // Assembling formatter and compiler into CodeEditor

editor.formatAndCompile("println(\"Hello, world!\")")

Now, we can test the Editor service easily without having to worry about the implementation of the Formatter and Compiler services. To test the Editor service, we can use a mock implementation of its dependencies:

class MockFormatter extends Formatter {
def format(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

class MockCompiler extends Compiler {
def compile(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

val formatter = new MockFormatter() // Creating mock formatter
val compiler = new MockCompiler() // Creating mock compiler
val editor = new EditorLive(formatter, compiler) // Assembling formatter and compiler into CodeEditor

import zio.test._

val expectedOutput = ???
for {
r <- editor.formatAndCompile("println(\"Hello, world!\")")
} yield assertTrue(r == expectedOutput)

Step 3: Binding Interfaces to their Implementations

In the previous step, we successfully decoupled the Editor service from concrete dependencies. However, there is still a problem. When the application grows, the number of dependencies might increase. So, instead of injecting the dependencies manually whenever needed, we would like to maintain a mapping from interfaces to their implementations in a container, and then whenever needed, we can ask for the required dependency from the container.

So we need a container that maintains this mapping. ZIO has a type-level map, called ZEnvironment, which can do that for us:

val scalaFormatter = new ScalaFormatter() // Creating Formatter
val scalaCompiler = new ScalaCompiler() // Creating Compiler
val myEditor = // Assembling Formatter and Compiler into an Editor
new EditorLive(
scalaFormatter,
scalaCompiler
)

val environment = ZEnvironment[Formatter, Compiler, Editor](scalaFormatter, scalaCompiler, myEditor)
// Map(
// Formatter -> scalaFormatter,
// Compiler -> scalaCompiler
// Editor -> myEditor
//)

Now, whenever we need an object of type Formatter, Compiler, or Editor, we can ask the environment for them.

object MainApp extends ZIOAppDefault {
def run =
environment.get[Editor].formatAndCompile("println(\"Hello, world!\")")
}

Here is another example:

val workflow: ZIO[Any, Nothing, Unit] =
for {
f <- environment.get[Formatter].format("println(\"Hello, world!\")")
_ <- environment.get[Compiler].compile(f)
} yield ()

Step 4: Effectful Constructors

Until now, we discussed the creation of services where the creation process was not effectful. But, assume in order to implement the Editor service, we need the Counter service, and the creation of Counter itself is effectful:

trait Counter {
def inc: UIO[Unit]
def dec: UIO[Unit]
def get: UIO[Int]
}

case class CounterLive(ref: Ref[Int]) extends Counter {
def inc: UIO[Unit] = ref.update(_ + 1)
def dec: UIO[Unit] = ref.update(_ - 1)
def get: UIO[Int] = ref.get
}

object CounterLive {
// Effectful constructor
def make: UIO[Counter] = Ref.make(0).map(new CounterLive(_))
}

class EditorLive(
formatter: Formatter,
compiler: Compiler,
counter: Counter
) extends Editor {
def formatAndCompile(code: String): UIO[String] = ???
}

To instantiate EditorLive we can't use the same technique as before:

val scalaFormatter = new ScalaFormatter() // Creating Formatter
val scalaCompiler = new ScalaCompiler() // Creating Compiler
val myEditor = // Assembling Formatter and Compiler into an Editor
new EditorLive(
scalaFormatter,
scalaCompiler,
CounterLive.make // Compiler Error: Type mismatch: expected: Counter, found: UIO[Counter]
)

We can use ZIO#flatMap to create the dependency graph but to make it easier, we have a special data type called ZLayer. It is effectful, so we can use it to create the dependency graph effectfully:

trait Formatter {
def format(code: String): UIO[String]
}

case class ScalaFormatter() extends Formatter {
def format(code: String): UIO[String] =
ZIO.succeed(code) // dummy implementation
}

object ScalaFormatter {
val layer: ULayer[Formatter] = ZLayer.succeed(ScalaFormatter())
}

trait Compiler {
def compile(code: String): UIO[String]
}

case class ScalaCompiler() extends Compiler {
def compile(code: String): UIO[String] = ZIO.succeed(code)
}
object ScalaCompiler {
val layer = ZLayer.succeed(ScalaCompiler())
}

trait Editor {
def formatAndCompile(code: String): UIO[String]
}

trait Counter {
def inc: UIO[Unit]
def dec: UIO[Unit]
def get: UIO[Int]
}

case class CounterLive(ref: Ref[Int]) extends Counter {
def inc: UIO[Unit] = ref.update(_ + 1)
def dec: UIO[Unit] = ref.update(_ - 1)
def get: UIO[Int] = ref.get
}

object CounterLive {
// Effectful constructor
def make: UIO[Counter] = Ref.make(0).map(new CounterLive(_))

val layer: ULayer[Counter] = ZLayer.fromZIO(CounterLive.make)
}

case class EditorLive(
formatter: Formatter,
compiler: Compiler,
counter: Counter
) extends Editor {
def formatAndCompile(code: String): UIO[String] = ???
}

object EditorLive {
val layer: ZLayer[Counter with Compiler with Formatter, Nothing, Editor] =
ZLayer {
for {
// we will discuss ZIO.service later
formatter <- ZIO.service[Formatter]
compiler <- ZIO.service[Compiler]
counter <- ZIO.service[Counter]
} yield EditorLive(formatter, compiler, counter)
}
}

object MainApp extends ZIOAppDefault {
val environment =
((ScalaFormatter.layer ++ ScalaCompiler.layer ++ CounterLive.layer) >>> EditorLive.layer).build

def run =
for {
editor <- environment.map(_.get[Editor])
_ <- editor.formatAndCompile("println(\"Hello, world!\")")
} yield ()
}
note

ZLayer is not only an effectful constructor, but also it supports concurrency and resource safety when constructing layers.

Step 5: Using ZIO Environment To Declare Dependencies

So far, we learned that the ZEnvironment can act as an IoC container. Whenever we need a dependency, we can ask for it from the environment:

val workflow: ZIO[Scope, Nothing, Unit] =
for {
env <- (ScalaFormatter.layer ++ ScalaCompiler.layer).build
f <- env.get[Formatter].format("println(\"Hello, world!\")")
_ <- env.get[Compiler].compile(f)
} yield ()

While this is a pretty good solution, there is a problem with it. Every time we need a dependency, we are asking for that instantly. In a large codebase, this imperative style of asking for dependencies can be tedious. This is an imperative style. It's better to make this declarative. So instead of asking for dependencies it is better to declare dependencies. Accordingly, we can use the R type-parameter of the ZIO data type which supports the declarative style:

val workflow: ZIO[Compiler with Formatter, Nothing, String] =
for {
f <- ZIO.service[Formatter]
r1 <- f.format("println(\"Hello, world!\")")
c <- ZIO.service[Compiler]
r1 <- c.compile(r1)
} yield r1

This is a much better solution. We just declare that we need the Compiler and the Formatter services using ZIO.service and then we compose pieces of our program to create the final application. The final workflow has all requirements in its type signature. For example, the ZIO[Compiler with Formatter, Nothing, String] type says that I need the Compiler and the Formatter services to produce the final result as a String.

Finally, we can provide all the dependencies through the ZIO#provideEnvironment method:

workflow.provideLayer(ScalaCompiler.layer ++ ScalaFormatter.layer)

Step 6: Automatic Dependency Graph Generation

For large applications, it can be tedious to manually create the dependency graph. ZIO has a built-in mechanism empowered by using macros to automatically generate the dependency graph. To use this feature, we can use the ZIO#provide method:

workflow.provide(ScalaCompiler.layer, ScalaFormatter.layer)

We should provide all required dependencies and then the ZIO will construct the dependency graph and provide that to our application.