Have you ever tried to learn Scala ZIO without success? If the answer is "yes", check this step by step introduction tutorial!
Introduction
I have been a Scala programmer for more than a decade, but always seeing it as a Java replacement or following an OOP approach. For this reason, I really wanted to go one step further in my Scala path and to learn FP. In order to do that I firstly decided to take a look at the ZIO ecosystem (it seemed to be a very big thing in the Scala world!), but I have to say that the learning curve was really stepped.
After spending hours and hours reading and testing, I overcame that curve and now it is time to share everything I learnt then. So, these series of post will try to provide the gentle introduction that I was looking for when I started this journey.
Although challenging at the beginning, ZIO is a very interesting Scala library that allows you to build asynchronous and concurrent applications following a very specific programming model... With this introduction I hope I can show you the basics on how to use it by building a real world web application.
What are we building
This post does not want to be a detailed guide of what is or how to use ZIO, but an example of building a real world application with it. If you want a detailed book about ZIO, you should consider taking a look at the Zionomicon.
We are going to build a simple web application to practice mental calculations using a microservice architecture that will receive GET, POST, etc. requests, will route those requests to their respective services, persist data to show a leaderboard, and communicate with other microservices through messages.
For this series, we are going to be adding functionality in an incremental way, starting from the basics and adding more things as we progress.
In this first post, we are going to code a simple endpoint that will return a random challenge, using a controller-service approach. We will use this service later on to create the challenges for the users.
You can find all the code of this post in the following repo: https://github.com/HyperCodeLab/zio-microservices/tree/master/version-1
Coding the application
Project configuration
For this project we’re going to use quite a usual project structure, with a main folder with all the logic of the application, and a test folder that replicates the structure of the main folder, but containing the tests for each service / model …
We’re also dividing the application in Models, Services and Controllers to have the code well organised and to follow standard industry patterns.
/ -
| -- build.sbt
| -- src
| -- | -- main // Main code of the application
| -- | -- | -- scala
| -- | -- | -- | -- ziomicroservices
| -- | -- | -- | -- | -- challenge
| -- | -- | -- | -- | -- | -- Main.scala
| -- | -- | -- | -- | -- | -- model
| -- | -- | -- | -- | -- | -- service
| -- | -- | -- | -- | -- | -- controller
| -- | -- test // tests of the application
| -- | -- | -- scala
| -- | -- | -- | -- ziomicroservices
| -- | -- | -- | -- | -- challenge
| -- | -- | -- | -- | -- | ...
Let’s start a Scala project by configuring the build.sbt. In this case we’re using Scala 3.3.0 and the latest ZIO version available, 2.0.15.
File: /build.sbt
ThisBuild / version := "0.1.0-SNAPSHOT"
ThisBuild / scalaVersion := "3.3.0"
val zioVersion = "2.0.15"
lazy val root = (project in file("."))
.settings(
name := "ZioMicroServices"
)
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % zioVersion,
"dev.zio" %% "zio-json" % "0.6.0",
"dev.zio" %% "zio-http" % "3.0.0-RC2"
)
The domain model
In a classical architecture, the models are defined based on the different objects / entities that we are going to use in our system. In this case, and to keep it simple for now, we’re going to model a “Challenge” that will consist of two values (A and B).
In order to model this simple class, we can basically create a case class with those attributes, and then define a companion object that will provide the different encoders / decoders to be able to serialize the class from and to JSON.
The key here is that we’re importing the zio.json._
library and using the default encoders/decoders that ZIO provides. Those encoders/decoders can be manually defined, but we will see that in a more complex example later on.
File: /src/main/scala/ziomicroservices/challenge/model/Challenge.scala
package ziomicroservices.challenge.model
import zio.json._
case class Challenge(valueA: Int, valueB: Int)
object Challenge:
given JsonEncoder[Challenge] = DeriveJsonEncoder.gen[Challenge]
given JsonDecoder[Challenge] = DeriveJsonDecoder.gen[Challenge]
This piece of code will generate a JSON like this:
{"valueA": ???, "valueB": ???}
And that’s it for our first domain model.
The business logic
Following a normal three-layer architecture, once we have the domains defined we will have to implement the business logic in individual services. This way we will create small components that we could use to build the functionality of our system, they will be (if feasible) as decoupled as possible, so we can easily test them (we will talk about testing later). And ZIO helps us with this, as it let us do Dependency Injection of the implementation that we need for a service. Let’s take a closer look at this.
RndmGeneratorService
The first service that we need to build is a Random number generator that will provide the values for our Challenge. Now it’s when we will really start using ZIO.
Following some coding good practices, let’s define an interface that our service will need to conform to.
In this case, the interface will have a unique method that will return a random Int
. Here you can see that the return type is a UIO[Int]
. This is a shortcut for ZIO[Any, Nothing, Int]
, meaning that we don’t have any requirement for the environment (Any), this action can not fail (Nothing), and it will produce an effect of type Int
.
If you didn’t understand the paragraph entirely, don’t worry, that’s normal 😅… It will become clear in the following examples. But this is what I meant when I said that ZIO has a really stepped learning curve.
Next to the trait, we also define its companion object that will register the method in the ZIO environment.
File: /src/main/scala/ziomicroservices/challenge/service/RandomGeneratorService.scala
package ziomicroservices.challenge.service
import zio._
trait RandomGeneratorService:
def generateRandomFactor(): UIO[Int]
object RandomGeneratorService:
def generateRandomFactor(): ZIO[RandomGeneratorService, Nothing, Int] = ZIO.serviceWithZIO[RandomGeneratorService](_.generateRandomFactor())
Let’s do the implementation now; as with any normal service, we need to extend the trait and implement the required methods.
Luckily for us, ZIO provides a Random
implementation that deals with all side effects from where we can use the nextIntBetween
method, so the code in this service is straight forward.
In order to make this service available to the ZIO environment, we need to provide a layer method in the companion object specifying how this service will be instantiated. And as this service doesn't have any dependency or can't fail, we can simply create the instance in a ZLayer.succeed(…)
.
File: /src/main/scala/ziomicroservices/challenge/service/RandomGeneratorServiceImpl.scala
package ziomicroservices.challenge.service
import zio._
case class RandomGeneratorServiceImpl() extends RandomGeneratorService:
val MINIMUM_FACTOR = 1
val MAXIMUM_FACTOR = 10
def generateRandomFactor(): UIO[Int] = Random.nextIntBetween(MINIMUM_FACTOR, MAXIMUM_FACTOR)
object RandomGeneratorServiceImpl:
def layer: ZLayer[Any, Nothing, RandomGeneratorServiceImpl] = ZLayer.succeed( RandomGeneratorServiceImpl() )
We could stop here and just bind this service to a controller and use it, but to make things a little bit more interesting, let’s implement another service that relies on the RandomGeneratorService
. This way we can learn how to do composition of services, use the ZIO environment and its dependency injection.
ChallengeService
Using the same pattern as before, we create an interface to define the contract that our implementation will need to follow.
It’s important to notice that in the interface definition we don’t want to couple this service with the previous one, as we would like to have different implementations with completely different logics.
File: /src/main/scala/ziomicroservices/challenge/service/ChallengeService.scala
package ziomicroservices.challenge.service
import zio._
import ziomicroservices.challenge.model.Challenge
trait ChallengeService:
def createRandomMultiplication(): UIO[Challenge]
object ChallengeService:
def createRandomMultiplication(): ZIO[ChallengeService, Nothing, Challenge] = ZIO.serviceWithZIO[ChallengeService](_.createRandomMultiplication())
Now for the implementation, we will need to define a case class that takes a RandomGeneratorService
as an argument, so we can use it to create our challenges, and then implement the createRandomMultiplication method.
The implementation of this method would be straight forward if it wasn’t for ZIO, as we need to return a ZIO[Any, Nothing, Challenge]
. But this implementation is still fairly easy; we can just use a for-comprehension calling out the randomGeneratorService
to get the values needed for our challenge, and just yield
an instance of the Challenge
. This will conform with ZIO’s needs.
The last part of the implementation is the companion object. It will allow us to expose this service to the ZIO environment as a Layer. ZLayers
are an interesting concept that ZIO just introduced, that allows us to easily do a dependency injection of a service implementation that you need somewhere in your code.
In this case, we can create a ZLayer
by specifying that we want to use a RandomGeneratorService
from the environment (we will see how we specify what implementation in a minute), and we basically create an instance of the ChallengeServiceImpl
passing the generator. This will indicate ZIO how to build this layer.
File: /src/main/scala/ziomicroservices/challenge/service/ChallengeServiceImpl.scala
package ziomicroservices.challenge.service
import zio._
import ziomicroservices.challenge.model.Challenge
case class ChallengeServiceImpl(randomGeneratorService: RandomGeneratorService) extends ChallengeService:
def createRandomMultiplication(): ZIO[Any, Nothing, Challenge] = {
for {
id1 <- randomGeneratorService.generateRandomFactor()
id2 <- randomGeneratorService.generateRandomFactor()
} yield (Challenge(id1, id2))
}
object ChallengeServiceImpl {
def layer: ZLayer[RandomGeneratorService, Nothing, ChallengeServiceImpl] = ZLayer {
for {
generator <- ZIO.service[RandomGeneratorService]
} yield ChallengeServiceImpl(generator)
}
}
This may seem a little bit complex, but it’s actually a quite clever way of building decoupled services, and the pattern is always going to be more or less similar, so if you understand what we did in here, you shouldn’t have any issues building independent ZIO services or with multiple dependencies.
The controller
Ok, so we have our services… but how do we use them now? Well, following a standard web service architecture, that’s normally done in a Controller. The controller basically routes the requests to an endpoint to a method of a service, only dealing with aspects like the body of the request, headers, server responses… nothing else (we shouldn’t have any business logic in here).
So, let’s route a simple GET request to our ChallengeService.
Most of the code is boilerplate… so the two interesting things are:
- Defining the ZIO environment like
Http[ChallengeService, Throwable, Request, Response]
: This way, we would be able to access theChallengeService
implementation from the ZIO Layers that we will define later. - Writing the pattern to the GET method pointing to the endpoint that we want to use, in this case
/challenges/random
, and using the ZIO magic to get from the environment theChallengeService
, and invoke the method that will generate the Challenge. After that, the only thing left is to map the output into a JSON response.
File: /src/main/scala/ziomicroservices/challenge/controller/ChallengeController.scala
package ziomicroservices.challenge.controller
import zio.json._
import zio.http._
import ziomicroservices.challenge.model.Challenge
import ziomicroservices.challenge.service.ChallengeService
object ChallengeController:
def apply(): Http[ChallengeService, Throwable, Request, Response] =
Http.collectZIO[Request] {
case Method.GET -> Root / "challenges" / "random" => {
ChallengeService.createRandomMultiplication().map(response => Response.json(response.toJson))
}
}
ZIO is normally combined with Tapir for describing HTTP API endpoints, but I don’t want to introduce it here, so we may come back to this controller to refactor it.
The main entrypoint
Finally, we reach the main entry point of the web app and where all the ZIO magic happens. Most part of this code is also boilerplate, so what you need to consider here is:
- In the variable
httpApps
we define all the Controllers that we are going to use, you can simply concatenate them by using++
. - In the provide method of the
Server
, we can provide the different implementations of the layers that we have define. This way, when we request a “service” from the ZIO environment in our code, it will pick the right implementation from the ones that we specify in here. Isn’t this amazing? Really nice concept. - The
defaultWithPort
indicates what port we want this application to run in, for this example we will use 8080.
File: /src/main/scala/ziomicroservices/challenge/Main.scala
package ziomicroservices.challenge
import zio.http.Server
import zio.{Scope, ZIO, ZIOAppArgs, ZIOAppDefault}
import ziomicroservices.challenge.controller.ChallengeController
import ziomicroservices.challenge.service.{ChallengeServiceImpl, RandomGeneratorServiceImpl}
object Main extends ZIOAppDefault {
def run: ZIO[Environment with ZIOAppArgs with Scope, Throwable, Any] =
val httpApps = ChallengeController()
Server
.serve(
httpApps.withDefaultErrorResponse
)
.provide(
Server.defaultWithPort(8080),
RandomGeneratorServiceImpl.layer,
ChallengeServiceImpl.layer
)
}
We can now compile and run, if you’re using sbt, that should be as simple as:
> sbt
compile
run
With this, the application is now running in the port that we defined before, 8080, so doing a simple request with postman will give us:
It works!
And one more thing before we finish…
Let’s not forget about testing
I’d normally follow a TDD approach for the work I do for my clients, but as I wanted to understand how ZIO works first, I have left it for the end of this post. Let’s try to take a look at how to write tests for what we have coded so far.
Configuration
Before we start, we need to add a few things to our sbt build, if not, we are not going to have the required dependencies for it, so your build.sbt should now look like this.
ThisBuild / version := "0.1.0-SNAPSHOT"
ThisBuild / scalaVersion := "3.3.0"
val zioVersion = "2.0.15"
lazy val root = (project in file("."))
.settings(
name := "ZioMicroServices",
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework"),
Test / fork := true
)
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % zioVersion,
"dev.zio" %% "zio-json" % "0.6.0",
"dev.zio" %% "zio-http" % "3.0.0-RC2"
)
libraryDependencies ++= Seq(
"dev.zio" %% "zio-test" % zioVersion % Test,
"dev.zio" %% "zio-test-sbt" % zioVersion % Test,
"dev.zio" %% "zio-test-magnolia" % zioVersion % Test,
"dev.zio" %% "zio-http-testkit" % "3.0.0-RC2" % Test
)
Let’s start writing some tests.
Notes: The way we have to write tests in ZIO is by extending the ZIOSpecDefault
, and adding our tests to the spec
method… a little bit unintuitive, but that’s how they have defined it. You will see it in action now.
Domain models tests
In these type of tests, we basically want to check that the serialization and deserialization of the objects from and to JSON works.
Hence, these two test should be trivial to understand.
File: /src/test/scala/ziomicroservices/challenge/model/ChallengeTest.scala
package ziomicroservices.challenge.model
import zio._
import zio.test._
import zio.json._
object ChallengeTest extends ZIOSpecDefault {
def spec = {
suite("Challenge Encoder / Decoding")(
test("converts from class to json") {
assertTrue(Challenge(3, 2).toJson == """{"valueA":3,"valueB":2}""")
},
test("converts from json to case class") {
assertTrue( """{"valueA":3,"valueB":2}""".fromJson[Challenge].getOrElse(null) == Challenge(3, 2))
}
)
}
}
Services Tests
Let’s write now a more interesting test, the implementation of the service that generates a random number.
And fortunately, ZIO has everything covered. Do you remember that so as to generate the random number we used the Random
implementation that ZIO provided? Well, ZIO also provides a TestRandom
implementation that allow us to set a seed, so the numbers will be generated following the same sequence.
Knowing that, understanding what’s happening in the following test should also be easy: we set the seed to 42, so after some trial and error, we know that the first random number generated will be 9, and we check that in a test.
File: /src/test/scala/ziomicroservices/challenge/service/RandomGeneratorServiceImplTest.scala
package ziomicroservices.challenge.service
import zio._
import zio.test._
import zio.test.Assertion.equalTo
object RandomGeneratorServiceImplTest extends ZIOSpecDefault {
def spec = {
suite("Test RandomGenerator Service")(
test("RandomGenerator service should provide random number back") {
for {
_ <- TestRandom.setSeed(42L)
mul <- RandomGeneratorServiceImpl().generateRandomFactor()
} yield assert(mul)(equalTo(9))
})
}
}
And for the service that generates the challenges the idea is exactly the same, but this time getting a ZIO.service
from the environment with the RandomGeneratorService
needed for our ChallengeServiceImpl
. This ZIO service is provided to the spec by using the provideLayer
method, similar to what we did in the Main entrypoint.
File: /src/test/scala/ziomicroservices/challenge/service/ChallengeServiceImplTest.scala
package ziomicroservices.challenge.service
import zio._
import zio.test._
import zio.test.Assertion.equalTo
import ziomicroservices.challenge.model.Challenge
object ChallengeServiceImplTest extends ZIOSpecDefault {
def spec = {
suite("Test RandomGenerator Service")(
test("RandomGenerator service should provide random number back") {
for {
_ <- TestRandom.setSeed(42L)
randomService <- ZIO.service[RandomGeneratorService]
challenge <- ChallengeServiceImpl(randomService).createRandomMultiplication()
} yield assert(challenge)(equalTo(Challenge(9, 4)))
})
}.provideLayer(
RandomGeneratorServiceImpl.layer
)
}
This test would have been harder if ZIO hadn’t provided the TestRandom
implementation, as we would have needed to mock the behaviour of our service. We will see how to do that in a later post.
Controller tests
For the controller, if it’s well designed it, it doesn’t normally need testing, as it doesn’t hold any logic, but I always find them useful at least to check that we are pointing to the right endpoints.
In this case, what we need to do is:
- Set the testing seed, so the output doesn’t change.
- Create an instance of our
ChallengeController
. - Specify the request that we want to perform, in this case a GET to the
/challenge/random
endpoint. - Run the request and check the output in the assert expression.
It’s also important to notice that I’m this case we re providing two layers, as the ChallengeController
needs a ChallengeService
implementation, and this one needs a RandomGeneratorService
implementation.
File: /src/test/scala/ziomicroservices/challenge/controller/RandomGeneratorControllerTest.scala
package ziomicroservices.challenge.controller
import zio._
import zio.http._
import zio.json._
import zio.test._
import zio.test.Assertion.equalTo
import ziomicroservices.challenge.model.Challenge
import ziomicroservices.challenge.service.{ChallengeServiceImpl, RandomGeneratorServiceImpl}
object RandomGeneratorControllerTest extends ZIOSpecDefault {
def spec = {
suite("Test RandomGenerator Controller")(
test("Controller should return right entity back when requested new challenge") {
TestRandom.setSeed(42L)
val app = ChallengeController()
val req = Request.get(URL(Root / "challenges" / "random"))
assertZIO(app.runZIO(req).map(x => x.body))(equalTo(Response.json(Challenge(3, 8).toJson).body))
}).provide(
RandomGeneratorServiceImpl.layer,
ChallengeServiceImpl.layer
)
}
}
Running the tests
Now that we have written our tests, we can run them with this simple command:
>sbt
test
And we should see the following output:
Wrap-up
And that’s it! It’s been a very long article, full of challenging concepts and ideas. But I hope it helps you to understand the basics of how a ZIO application looks like.
In the next post I’ll go a step further and implement the functionality to answer the challenge through a POST request, and store that information, introducing the concept of repository.
Thanks for reading!