What are the benefits of the ZIO modules with ZLayers
Three months ago I wrote this Blog:
Meanwhile ZIO took really of and one of my concerns got a major update with RC18:
I still struggle with ZIO, for example providing the runtime/ environment…
So I updated all my ZIO projects with ZLayers!
In this Blog I will focus on my experiences I made when migrating to the new Modules and Layers of ZIO.
Spoiler: The migration was straight forward!
I structured this Blog in three parts:
- Listing the Module parts as they were in my project.
- Showing them after migrating to ZLayer.
- List my personal improvements of ZLayer.
1. Old Style
So how did a Module look like before RC18? I just extracted the main parts of the official documentation. And there were quite some parts:
Module definition
trait Components extends Serializable {
val components: Components.Service[ComponentsEnv]
}
The Components
trait is the module, which is just a container for the Components.Service
.
Service
object Components { trait Service[R] {
def load[T <: Component : ClassTag](ref: CompRef): RIO[R, T]
def render(component: Component): RIO[R, String]
}
}
The service is just an ordinary interface, defining the capabilities it provides.
- By convention it is in the modules companion object.
R
is for the environment which you define for the whole Service.- You can have different environments for different Service Implementations.
Access Methods
The Access Methods allow you to use the Service without knowing which Implementation it gonna be. As you can see they are in a strangely named object
. For example:Components.>.render(myComp)
Implementation
Each implementation can specify its own requirements (environment R
). In our example ComponentsEnv
which resolves to the standard Console
.
The Program
The program itself does not care what its implementation is. All it says it requires an AppEnv
in the end.
Run the Program
And here at last we provide our Program with the implementations. As seen above it requires aConsole
and a Components
implementation.
2. New Style
Ok, now the actual part we are exited about.
So let’s see if ZLayers are so awesome.
I will not explain the different concepts and data types, as they are well explained in the official documentation.
Module definition
type Components = Has[Components.Service]
The Components
is just a type alias for the ‘module’, which describes a dependency to the service Components.Service
.
Has[A]
represents a dependency on a service of typeA
.
Service
The only difference here is that the environment R
is not set on the Service but on the methods. So each functionality defines its on requirements.
Also interesting is that all Service implementations have the same requirements. This means if you need different environments in your implementation, you cannot provide it via the environment R
anymore.
Access Methods
Nothing much changed here:
- There is no extra object for the Access Methods.
- The Service instance is retrieved from the HasSyntax with some magic.
Implementation
def live(service: Service): ZLayer.NoDeps[Nothing, Components] = ZLayer.succeed(service)
The implementation is now just creating a ZLayer with a Service.
In our case as we have different Service implementations we provide a function that takes a Service implementation and creates a ZLayer from it.
The Service implementation looks the same as before, except that we do not need to implement the whole Module anymore!
The Program
No change here, I had only to adjust the imports. From Components.>.{load, render}
toComponents.{load, render}.
Run the Program
Two things have changed:
- We must not provide the standard environment Console.
- We call the function that creates our ZLayer with our Service Implementation.
How to pass implementation specific environments?
There was one thing that I did not grasp at first. If the Service implementation can not have its own environment R
. How would you achieve this now?
My first thought was to just add it to the Service definition.
trait Service {
def myFunct(): RIO[Console, String]
}
This implies that every implementation needs to be adjusted, if you want to add an environment (e.g. Clock). So no that would be really bad.
Ok why not just add it as a dependency, like:
def live(console: Console): ZLayer.NoDeps[Nothing, NumberService] = ZLayer.succeed(new Service{
def myFunct(): IO[String] = console.putStrLine....
})
Better, but now the question arises — why would we need the environment at all?
The answer of course was Composing ZLayers vertically. So our implementation looks now:
val live: ZLayer[Console, Nothing, NumberService] =
ZLayer.fromFunction{ console: Console =>
console.putStrLine....
And here is how you can provide it:
...}.provideCustomLayer((Console.live >>> NumberService.live)
Improvements
Here are my personal Top five improvements of this redesign:
- The Environment is now set on each Functionality. So not all functionalities must have the same Requirements. Also the whole setup is now much cleaner without
R
everywhere — or was itAny
😏. - Composing ZLayers horizontally and vertically.
- A module does not need to be implemented, just its Services.
- All Implementations have the same method signatures (same environment). Extra requirements can be added via vertical composition or as dependencies. This is more logical for me.
- Providing the environment is simpler, as the standard Environments are already provided, so you have only to provide your own custom ZLayers.
Conclusion
This update makes modules more accessible and likely to be adopted. One step closer to production ready.
And yes ZLayers are awesome — can’t wait for ZIO Release 1.0!
References
The Github Projects that I migrated and used in this Blog:
I migrated the Github project according to this documentation:
The old version of the module doc can be found here:
The images are from Central Switzerland — so if you are in the region && (want to Bike || talk about ZIO) > let me know😏.