Manual Layer Construction
We said that we can think of the ZLayer
as a more powerful constructor. Constructors are not composable, because they are not values. While a constructor is not composable, ZLayer
has a nice facility to compose with other ZLayer
s. So we can say that a ZLayer
turns a constructor into values.
In a regular ZIO application we are not required to build the dependency graph through composing layers tougher. Instead, we can provide all dependencies to the ZIO application using ZIO#provide
, and the ZIO will create the dependency graph manually under the hood. Therefore, use manual layer composition if you know what you're doing.
Vertical and Horizontal Composition
Assume we have several services with their dependencies, and we need a way to compose and wire up these dependencies to create the dependency graph of our application. ZLayer
is a ZIO solution for this problem, it allows us to build up the whole application dependency graph by composing layers horizontally and vertically.
Horizontal Composition
Layers can be composed together horizontally with the ++
operator. When we compose layers horizontally, the new layer requires all the services that both of them require and produces all services that both of them produce. Horizontal composition is a way of composing two layers side-by-side. It is useful when we combine two layers that don't have any relationship with each other.
We can compose fooLayer
and barLayer
horizontally to build a layer that has the requirements of both, to provide the capabilities of both, through fooLayer ++ barLayer
:
import zio._
val fooLayer: ZLayer[A, Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[C, Nothing , D] = ??? // C ==> D
val horizontal: ZLayer[A & C, Throwable, B & D] = // A & C ==> B & D
fooLayer ++ barLayer
Vertical Composition
We can also compose layers vertically using the >>>
operator, meaning the output of one layer is used as input for the subsequent layer, resulting in one layer with the requirement of the first, and the output of the second.
For example if we have a layer that requires A
and produces B
, we can compose this with another layer that requires B
and produces C
; this composition produces a layer that requires A
and produces C
. The feed operator, >>>
, stack them on top of each other by using vertical composition. This sort of composition is like function composition, feeding an output of one layer to an input of another:
import zio._
val fooLayer: ZLayer[A, Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[B, Nothing , C] = ??? // B ==> C
val horizontal: ZLayer[A, Throwable, C] = // A ==> C
fooLayer >>> barLayer
Hidden Versus Passed-through Dependencies
ZLayer has a passthrough
operator which returns a new layer that produces the outputs of this layer but also passes-through the inputs:
import zio._
val fooLayer: ZLayer[A, Nothing, B] = ??? // A ==> B
val result1 : ZLayer[A, Nothing, A & B] = // A ==> A & B
fooLayer.passthrough
val result2 : ZLayer[A, Nothing, A & B] = // A ==> A & B
ZLayer.service[A] ++ fooLayer
// (A ==> A) ++ (A ==> B)
// (A ==> A & B)
By default, the ZLayer
hides intermediate dependencies when composing vertically. For example, when we compose fooLayer
with barLayer
vertically, the output would be a ZLayer[A, Throwable, C]
. This hides the dependency on the B
layer. By using the above technique, we can pass through hidden dependencies.
Let's include the B
service into the upstream dependencies of the final layer using the ZIO.service[B]
. We can think of ZIO.service[B]
as an identity function (B ==> B
).
import zio._
val fooLayer: ZLayer[A, Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[B, Throwable, C] = ??? // B ==> C
val finalLayer: ZLayer[A & B, Throwable, C] = // A & B ==> C
(fooLayer ++ ZLayer.service[B]) >>> barLayer
// ((A ==> B) ++ (B ==> B)) >>> (B ==> C)
// (A & B ==> B) >> (B ==> C)
// (A & B ==> C)
Or we may want to include the middle services in the output channel of the final layer, resulting in a new layer with the inputs of the first layer and the outputs of both layers:
import zio._
val fooLayer: ZLayer[A, Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[B, Throwable, C] = ??? // B ==> C
val finalLayer: ZLayer[A, Throwable, B & C] = // A ==> B & C
fooLayer >>> (ZLayer.service[B] ++ barLayer)
// (A ==> B) >>> ((B ==> B) ++ (B ==> C))
// (A ==> B) >>> (B ==> B & C)
// (A ==> B & C)
We can do the same with the >+>
operator:
import zio._
val fooLayer: ZLayer[A, Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[B, Throwable, C] = ??? // B ==> C
val finalLayer: ZLayer[A, Throwable, B & C] = // A ==> B & C
fooLayer >+> barLayer
This technique is useful when we want to defer the creation of some intermediate services and require them as part of the input of the final layer. For example, assume we have these two layers:
import zio._
val fooLayer: ZLayer[A , Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[B & C, Throwable, D] = ??? // B & C ==> D
val finalLayer: ZLayer[A & C, Throwable, D] = // A & C ==> B & D
fooLayer >>> barLayer
So we can defer the creation of the C
layer using ZLayer.service[C]
:
import zio._
val fooLayer: ZLayer[A , Throwable, B] = ??? // A ==> B
val barLayer: ZLayer[B & C, Throwable, D] = ??? // B & C ==> D
val layer: ZLayer[A & C, Throwable, D] = // A & C ==> D
(fooLayer ++ ZLayer.service[C]) >>> barLayer
// ((A ==> B) ++ (C ==> C)) >>> (B & C ==> D)
// (A & C ==> B & C) >>> (B & C ==> D)
// (A & C ==> D)
Here is an example in which we passthrough all requirements to bake a Cake
so all the requirements are available to all the downstream services:
import zio._
trait Baker
trait Ingredients
trait Oven
trait Dough
trait Cake
lazy val baker : ZLayer[Any, Nothing, Baker] = ???
lazy val ingredients: ZLayer[Any, Nothing, Ingredients] = ???
lazy val oven : ZLayer[Any, Nothing, Oven] = ???
lazy val dough : ZLayer[Baker & Ingredients, Nothing, Dough] = ???
lazy val cake : ZLayer[Baker & Oven & Dough, Nothing, Cake] = ???
lazy val all: ZLayer[Any, Nothing, Baker & Ingredients & Oven & Dough & Cake] =
baker >+> // Baker
ingredients >+> // Baker & Ingredients
oven >+> // Baker & Ingredients & Oven
dough >+> // Baker & Ingredients & Oven & Dough
cake // Baker & Ingredients & Oven & Dough & Cake
This allows a style of composition where the >+>
operator is used to build a progressively larger set of services, with each new service able to depend on all the services before it. If we passthrough dependencies and later want to hide them we can do so through a simple type ascription:
lazy val hidden: ZLayer[Any, Nothing, Cake] = all
The ZLayer
makes it easy to mix and match these styles. If we build our dependency graph more explicitly, we can be confident that dependencies used in multiple parts of the dependency graph will only be created once due to memoization and sharing.
Using these simple operators we can build complex dependency graphs.
Updating Local Dependencies
Given a layer, it is possible to update one or more components it provides. We update a dependency in two ways:
- Using the
update
Method — This method allows us to replace one requirement with a different implementation:
import zio._
val origin: ZLayer[Any, Nothing, String & Int & Double] =
ZLayer.succeedEnvironment(ZEnvironment[String, Int, Double]("foo", 123, 1.3))
val updated1 = origin.update[String](_ + "bar")
val updated2 = origin.update[Int](_ + 5)
val updated3 = origin.update[Double](_ - 0.3)
Here is an example of updating a config layer:
import zio._
import java.io.IOException
case class AppConfig(poolSize: Int)
object MainApp extends ZIOAppDefault {
val myApp: ZIO[AppConfig, IOException, Unit] =
for {
config <- ZIO.service[AppConfig]
_ <- Console.printLine(s"Application config after the update operation: $config")
} yield ()
val appLayers: ZLayer[Any, Nothing, AppConfig] =
ZLayer(ZIO.succeed(AppConfig(5)).debug("Application config initialized"))
val updatedConfig: ZLayer[Any, Nothing, AppConfig] =
appLayers.update[AppConfig](c =>
c.copy(poolSize = c.poolSize + 10)
)
def run = myApp.provide(updatedConfig)
}
// Output:
// Application config initialized: AppConfig(5)
// Application config after the update operation: AppConfig(15)
- Using Horizontal Composition — Another way to update a requirement is to horizontally compose in a layer that provides the updated service. The resulting composition will replace the old layer with the new one:
import zio._
val origin: ZLayer[Any, Nothing, String & Int & Double] =
ZLayer.succeedEnvironment(ZEnvironment[String, Int, Double]("foo", 123, 1.3))
val updated = origin ++ ZLayer.succeed(321)
Let's see an example of updating a config layer:
import zio._
import java.io.IOException
case class AppConfig(poolSize: Int)
object MainApp extends ZIOAppDefault {
val myApp: ZIO[AppConfig, IOException, Unit] =
for {
config <- ZIO.service[AppConfig]
_ <- Console.printLine(s"Application config after the update operation: $config")
} yield ()
val appLayers: ZLayer[Any, Nothing, AppConfig] =
ZLayer(ZIO.succeed(AppConfig(5)).debug("Application config initialized"))
val updatedConfig: ZLayer[Any, Nothing, AppConfig] =
appLayers ++ ZLayer.succeed(AppConfig(8))
def run = myApp.provide(updatedConfig)
}
// Output:
// Application config initialized: AppConfig(5)
// Application config after the update operation: AppConfig(8)
Cyclic Dependencies
The ZLayer
mechanism makes it impossible to build cyclic dependencies, making the initialization process very linear, by construction.