Idiomatic dependency injection for ZIO applications in Scala
I often hear online in Scala-related discussions that ZLayer
is "too complex" or "unnecessary". Those statements couldn’t be more different from my own experience: I think ZLayer
is an incredible lifesaver! While it is true that it had some issues in earlier versions of ZIO (those who remember the Has
datatype know what I’m talking about!), a number of major improvements have been made since. In this article, I will show idiomatic usage of ZLayer
for dependency injection and hope to demonstrate how it allows doing complex things in a very simple way.
Note: I have been working with ZIO applications for many years (before 1.0!) and I am currently working on the backend of a large online multiplayer game, entirely written in Scala and using ZIO. The examples in this article are inspired by that codebase.
A typical service definition
Let’s start with a simple service. The game I’m working on allows users to chat with each other. To prevent them from using offensive words, each chat message is sent to an external service (called WordFilter
) that will mask the banned parts.
To implement this, we start by creating an interface (a trait in Scala). Programming to interfaces is a good practice that has many benefits. Separation of concerns makes your code easier to understand and maintain: you’re not mixing your business logic with unrelated concerns such as database access or API calls. Changing a backend service or provider will only affect a given interface implementation and not the rest of the code. Another obvious benefit is testability, as you are able to provide a different implementation of the interface for testing.
trait WordFilter {
def maskChat(msg: String, language: Option[Language]): Task[String]
}
Our interface WordFilter
has a single method maskChat
that takes a message and an optional language and returns a new message. The Task
return type implies that the method may have side effects or may fail with an Exception
.
Now let’s work on the implementation. We want to call our external service, which is a simple REST API. We need two things: the URL of that service and an HTTP client. For that, we are going to use the library sttp.
Our service has two "dependencies," which we can define as follows:
type SttpClient = sttp.client3.SttpBackend[Task, Any]
case class WordFilterConfig(url: String)
The idiomatic way to create our service implementation is to create a class
extending the interface with all our dependencies as parameters.
class WordFilterLive(config: WordFilterConfig, sttp: SttpClient)
extends WordFilter {
def maskChat(msg: String, language: Option[Language]): Task[String] =
??? // the implementation itself is not important here
}
An important thing to note is that using the ZIO environment for dependencies by making maskChat
return RIO[WordFilterConfig & SttpClient, String]
is an anti-pattern and should be avoided. By doing this, you are “leaking” your dependency to the rest of the code and making it less readable, breaking the separation of concerns. Worse, these dependencies might change in the future (e.g., if we switch sttp with something else), and it will cause a lot of changes to update all usages. The ZIO environment should only be used for contextual information which is eliminated at some boundary (user context, transaction context, etc.). If your dependency always has the same value for the lifespan of the application, move it to your service class.
When testing, we don’t want to hit our actual service; we simply want to return the original message. For that, we can create another service implementation which does not require any dependency.
class WordFilterTest extends WordFilter {
def maskChat(msg: String, language: Option[Language]): Task[String] =
ZIO.succeed(msg)
}
If we used the anti-pattern mentioned above, we would have to provide a configuration value and an sttp client even when using the test implementation, which would be unfortunate. Dependencies are tied to the implementation, not the interface.
So far, we’ve just used Scala standard practices. Why do we need ZLayer
then? It is useful for constructing services and combining them together.
Building the service
To build our WordFilterLive
, we need the sttp backend and our configuration value. First, let’s talk about the sttp backend. We could create a new one when we construct WordFilterLive
; however, we might have other services needing it, and it is recommended to have a single backend instance for the whole application. So we are going to get it using ZIO.service
instead. This operation will be done inside a ZLayer
so that it is done once when constructing our services.
For WordFilterConfig
, we don’t need to use the environment: instead, we can simply use ZIO.config
, which will look for a given
Config[WordFilterConfig]
in scope (an implicit
with Scala 2). The Config
typeclass defines which config keys to read and how to parse the values. You can define this given
manually, but also derive it automatically using deriveConfig
. In our case, we use environment variables to configure our application, so we use the @name
annotation together with deriveConfig
to define the corresponding environment variable and parse it into a String
.
import zio.config.derivation.name
import zio.config.magnolia.deriveConfig
case class WordFilterConfig(
@name("WORDFILTER_URL") url: String
)
object WordFilterConfig {
given Config[WordFilterConfig] = deriveConfig[WordFilterConfig]
}
This code means that when trying to access WordFilterConfig
, the application will look at the key WORDFILTER_URL
inside the configuration key-value map, parse it into a String
, and construct an instance of WordFilterConfig
with that value for url
.
Here’s our layer definition:
val live: ZLayer[SttpClient, Nothing, WordFilter] =
ZLayer {
for {
config <- ZIO.config[WordFilterConfig]
sttp <- ZIO.service[SttpClient] // requires a SttpClient
} yield WordFilterLive(config, sttp)
}
The inner for-comprehension returns a ZIO[SttpClient, Nothing, WordFilter]
, but we wrap it inside ZLayer
to change the type from ZIO
to ZLayer
. We will see why in a moment.
What if our service required a dozen other services and configuration classes? Wouldn’t it be a bit tedious to write that code? It would, and in fact, we don’t need to write it at all! Instead, we can just call ZLayer.derive
as shown below (I also included the layer for the test implementation):
val live: ZLayer[SttpClient, Nothing, WordFilter] =
ZLayer.derive[WordFilterLive]
val test: ZLayer[Any, Nothing, WordFilter] =
ZLayer.derive[WordFilterTest]
This code will do exactly the same thing as the code we wrote earlier. It uses a macro that first checks the fields needed to build a WordFilterLive
. Then, for each field type, it checks if there is a given
Config
for it in scope. If a Config
is found, it will call ZIO.config
to get that value; otherwise, it will call ZIO.service
and the result ZLayer
will require that service. In the end, the macro will generate code that matches the one created manually.
Now, what if our service construction required additional values, such as a Queue
or a Hub
? ZLayer.derive
can handle that too (it will use the unbounded
constructors). What if we needed a type that is not handled out of the box, such as a RateLimiter
or a Cache
? For that, you can define a given
instance of ZLayer.Derive.Default
that describes how to create a value for your type, and it will be used by the ZLayer.derive
macro.
case class MyService(
config: MyConfig,
anotherService: AnotherService,
rateLimiter: RateLimiter
)
given ZLayer.Derive.Default.WithContext[Any, Nothing, RateLimiter] =
ZLayer.Derive.Default.fromZIO(RateLimiter.make)
val live: ZLayer[AnotherService, Nothing, MyService] =
ZLayer.derive[MyService] // will run RateLimiter.make
Of course, you can always write the layer construction manually, especially if you have a lot of custom logic to execute. But in most cases, derive
allows you to do the job much more succinctly.
Providing the service
We have a ZLayer
for our WordFilter
service. How do we use it? To understand it better, let’s add two services to our application: first, we have ChatService
, which exposes the chat
API and will use the WordFilter
dependency in its implementation. Finally, we have GrpcServer
, which exposes all our services to clients on a given port. It has a start
method that starts the server and waits until the application is interrupted (hence the Nothing
return type).
class ChatService(wordFilter: WordFilter) {
def chat(message: String): Task[Unit] = ???
}
object ChatService {
val live: ZLayer[WordFilter, Nothing, ChatService] =
ZLayer.derive[ChatService]
}
class GrpcServer(chatService: ChatService) {
def start: Task[Nothing] = ???
}
object GrpcServer {
val live: ZLayer[ChatService, Nothing, GrpcServer] =
ZLayer.derive[GrpcServer]
}
Nothing fancy, we just reused the same pattern as before (I didn’t use a trait
for simplicity; normally, I use a trait for everything except the top layer).
Now let’s look at our main function (the run
function in ZIO apps). The main thing we want to do there is start GrpcServer
. We will use ZIO.serviceWithZIO
to access GrpcServer
from the ZIO environment and call the start
function.
object App extends ZIOAppDefault {
val run = ZIO.serviceWithZIO[GrpcServer](_.start)
}
If we only do that, we will get a compile error:
That is because we need to tell the program how to build a GrpcServer
. If you are familiar with ZIO, you know this is done by calling the .provide
method and pass a ZLayer
as parameter. Let’s try it:
val run =
ZIO
.serviceWithZIO[GrpcServer](_.start)
.provide(GrpcServer.live)
Now we get another error:
The error is very self-explanatory: we provided a GrpcServer
, but this layer (GrpcServer.live
) itself requires a ChatService
, so we need to provide it too. And so on until all our dependencies are provided, which in our case is as follows:
val run =
ZIO
.serviceWithZIO[GrpcServer](_.start)
.provide(
GrpcServer.live,
ChatService.live,
WordFilter.live,
HttpClientZioBackend.layer() // creates a SttpClient
)
It compiles! And we can run our application successfully.
Let’s now say we want a test app where we are not using the real WordFilter
API, so let’s replace WordFilter.live
with WordFilter.test
. It compiles, but we get a warning:
Since we don’t use the real API, we don’t need the HTTP client anymore, so we don’t need the sttp layer or the configuration environment variable at runtime.
What if we provided both live
and test
?
Once again, the error message is very clear.
The beauty of ZLayer
is that we don’t need to plug things together manually. The provide
macro will look at all the types necessary and build a tree of dependencies at compile time. Any anomaly will cause a compile error: a missing dependency, an unnecessary dependency, a dependency cycle… Error messages are great, as we saw above, and will tell you exactly what the problem is.
Once it compiles, you know there won’t be any surprises at runtime: the tree will be executed from top to bottom, with independent services being created in parallel. Any service that is used in multiple places will be created only once (unless you explicitly specify that you want different ones using the fresh
method on layers).
Conclusion
As we’ve seen, there is nothing overly complicated with ZLayer
, especially if you are already familiar with ZIO
. There is a single pattern to follow: declaring your services as classes containing the other services that they may use. Once you have that, use ZLayer.derive
to create the appropriate ZLayer
values (in most cases, it will be a one-liner). Finally, provide all your layers in a big, flat list and let the compiler do the work for you and tell you if you missed something. That’s it!
I have been using this pattern in multiple applications, some very large with hundreds of services and it has been working beautifully.
The whole code of this example is available in this Gist and you can run it with the following command: export WORDFILTER_URL="???" && scala run .
.
Subscribe to my newsletter
Read articles from Pierre Ricadat directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Pierre Ricadat
Pierre Ricadat
Software Architect | Scala Lover | Creator of Caliban and Shardcake