Exit e-book
Show all chapters
A deep look into modular applications with ZIO
A deep look into modular applications with ZIO
Mastering Modularity in ZIO with Zlayer

A deep look into modular applications with ZIO

As you may already know, ZIO is designed around three type parameters:

ZIO[-R, +E, +A]

You may also remember that a nice mental model of the ZIO data type is the following:

ZEnvironment[R] => Either[E, A]

This means a ZIO effect needs an environment of type ZEnvironment[R] to run (we will discuss in the following section in more detail about this ZEnvironment type), hence we need to fulfill this requirement in order to make the effect runnable. More concretely, this ZEnvironment[R]type represents a dependency on a service or several services that are needed for running the effect. Therefore, let’s now discuss how Services are defined in ZIO (by the way, if you need a more in-depth introduction to ZIO, you can take a look at this article in the Scalac blog).

About Services in ZIO

As mentioned in the ZIO documentation page: “A service is a group of functions that deals with only one concern. Keeping the scope of each service limited to a single responsibility improves our ability to understand code, in that we need to focus only on one topic at a time without juggling too many concepts together in our head”.

The idea is that ZIO allows us to define services and use them to create different application layers that rely on each other. This means each layer depends on the layers immediately below it, although it doesn’t know anything about their implementation details. This is a really powerful concept because it improves composability and testability (because you can easily change each of the service’s implementations without affecting other layers).

Now, if you are thinking about how to define these services, ZIO provides us with a nice recipe to follow when defining a new service. This recipe should be familiar to object-oriented programmers:

zio modules

Don’t worry if this all seems too abstract at the moment because we are going to be applying this recipe to implement the Tic-Tac-Toe application later. The only important thing, for now, is to get to know ZLayer, a very important data type mentioned in this recipe, and which is related to another very important one: ZEnvironment. So let’s discuss those now.

The ZEnvironment data type

As mentioned in the ZIO documentation page, a ZEnvironment[R] is a built-in type-level map for the ZIO data type which is responsible for maintaining the environment of a ZIO effect. The ZIO data type uses this map (you can think of it as a Map[ServiceType, ServiceImplementation]) to maintain all the environmental services and their implementations.

It’s important to mention that ZEnvironment replaces the old Has data-type of ZIO 1.0, which wasn’t very user-friendly. Also, ZEnvironment is now subsumed into the ZIO data type itself, which improves its usability even further.

A little example on how ZEnvironment is used

Let’s now see a very simple example of how ZEnvironment can be used. Let’s say we have a getCurrentUser effect that requires some services (Logging and HttpClient) from the environment:

val getCurrentUser: URIO[Logging with HttpClient, User] = ???

Our services are defined like this:

trait Logging                             // Service interface 
final case class LoggingLive() extends Logging // Service implementation 

trait HttpClient 
final case class HttpClientLive() extends HttpClient

So, in order for ZIO to be able to execute the getCurrentUser effect, we need to provide the dependencies it needs. For that, we first need to create a ZEnvironment containing all the required dependencies:

val env: ZEnvironment[Logging with HttpClient] =
ZEnvironment(LoggingLive(), HttpClientLive())

Now, we can provide this environment to getCurrentUser, by calling ZIO#provideEnvironment:

val getCurrentUserWithEnv: UIO[User] = getCurrentUser.provideEnvironment(env)

And now, we have a ZIO effect that does not require any environment, because we have provided all the required dependencies, and ZIO will be able to execute it. Now a final word about ZEnvironment, and it’s that normally you won’t need to work directly with it. Because there’s a more powerful data type that you can use instead to provide required dependencies to a ZIO effect: ZLayer.

The ZLayer data type

The ZLayer data type is an immutable value which contains a pure description for building a ZEnvironment[ROut], starting from a value RIn, possibly producing an error E during creation:

ZLayer[-RIn, +E, +ROut]

If you think about it, ZLayer is a bit like a class constructor. However, while a class constructor describes how you build objects of some class, it doesn’t describe the process as a value, but ZLayer does! So:

  • A class constructor is not a value in the same way a statement is not a value
  • As ZIO effects turn statements into values, ZLayers turn constructors into values
  • ZLayers can also describe the destruction process of a service, not just its construction!

Because ZLayers are values, they are highly compositional, and can be combined in two fundamental ways:

  • Horizontally: To build a layer that has the requirements and provides the capabilities of both layers, we use the ++ operator.
  • Vertically: In this case, the output of one layer is used as the input for the subsequent layer, resulting in a layer with the requirement of the first and the output of the second layer. We use the >>> operator for this.

Again, don’t panic if this doesn’t make too much sense for you at the moment, because we are going to be applying both the horizontal and vertical compositions when we implement the Tic-Tac-Toe application and everything will become clearer.

Why ZLayer?

Now you may be thinking: Do we really need ZLayer? Isn’t ZEnvironment enough to provide dependencies to a ZIO effect?

The answer is that, in small applications with a limited number of services, ZEnvironment would be enough. However, in real life a lot of applications consist of thousands or millions of lines of code, contain several different services and have different test and production implementations for each service. Manually wiring all of these services becomes a tedious exercise, and it’s an opportunity for people to make the same mistakes over and over again.

What you want instead is some automatic Dependency Injection mechanism, which gives you structure, lots of it, so you basically make it very simple for people to add new services, new implementations, and enforce best practices being followed. That’s what ZLayer is all about! It helps you to structure large-scale applications in a way that scales.

By the way, best practices that are automatically enforced by ZLayer (you don’t even need to think about them!) include the following:

  • When you’re wiring up your application dependency graph, you should try to do that in parallel, to reduce the bootstrap load time. Obviously, you can’t wire up your whole dependency graph in parallel because sometimes you have sequential parts, and ZLayer knows exactly which parts can be constructed in parallel and which parts sequentially.
  • Also, whenever any component of your application is no longer being used, you should safely deallocate resources, such as open file descriptors or network connections.

ZLayer type aliases

Finally, it’s worth mentioning that ZIO provides some type aliases for the ZLayer data type which are very useful when representing some common use cases. The good news is that the logic for defining these type aliases is practically the same as that applied for defining the ZIO type aliases. Here’s the complete list:

  • TaskLayer[+ROut] = ZLayer[Any, Throwable, ROut]: This means a TaskLayer[ROut] is a ZLayer that:
    • Doesn’t require an input (that’s why the RIn type is replaced by Any)
    • Can fail with a Throwable
    • Can succeed with an ROut
  • ULayer[+ROut] = ZLayer[Any, Nothing, ROut]: This means a ULayer[ROut] is a ZLayer that:
    • Doesn’t require an input
    • Can’t fail
    • Can succeed with an ROut
  • RLayer[-RIn, +ROut] = ZLayer[RIn, Throwable, ROut]: This means an RLayer[RIn, ROut] is a ZLayer that:
    • Requires an input RIn
    • Can fail with a Throwable
    • Can succeed with an ROut
  • Layer[+E, +ROut] = ZLayer[Any, E, ROut]: This means a Layer[E, ROut] is a ZLayer that:
    • Doesn’t require an input
    • Can fail with an E
    • Can succeed with an ROut
  • URLayer[-RIn, +ROut] = ZLayer[RIn, Nothing, ROut]: This means a URLayer[RIn, ROut] is a ZLayer that:
    • Requires an input RIn
    • Can’t fail
    • Can succeed with an ROut

Now, if you are wondering how to create and use ZLayers, stay tuned because we are going to be seeing how easy that is to do in the next section.