How to build a Scala Zio CRUD Microservice
1. Overview
This tutorial will introduce how to build from scratch, a REST microservice using the ZIO framework, and examples of ZIO dependency injection, ZIO HTTP, JSON, JDBC, and others from the ZIO environment. source code be found in our repository
2. What's ZIO
ZIO is a next-generation framework for building cloud-native applications on the JVM. With a beginner-friendly yet powerful functional core, ZIO lets developers quickly build best-practice applications that are highly scalable, testable, robust, resilient, resource-safe, efficient, and observable.
At the heart of ZIO is a powerful data type called ZIO, which is the fundamental building block for every ZIO application.
The ZIO data type is called a functional effect, and represents a unit of computation inside a ZIO application.
The type parameters of the ZIO data type have the following meanings:
- R - Environment Type. The environment type parameter represents the type of contextual data that is required by the effect before it can be executed. For example, some effects may require a connection to a database, while others might require an HTTP request, and still others might require a user session. If the environment type parameter is Any, then the effect has no requirements, meaning the effect can be executed without first providing it any specific context.
- E - Failure Type. The failure type parameter represents the type of error that the effect can fail with when it is executed. Although Exception or Throwable are common failure types in ZIO applications, ZIO imposes no requirement on the error type, and it is sometimes useful to define custom business or domain error types for different parts of an application. If the error type parameter is Nothing, it means the effect cannot fail.
- A - Success Type. The success type parameter represents the type of success that the effect can succeed with when it is executed. If the success type parameter is Unit, it means the effect produces no useful information (similar to a void-returning method), while if it is Nothing, it means the effect runs forever, unless it fails.
3. Writing ZIO Services
ZIO recommends writing services using the Service Pattern, which is very similar to the object-oriented way of defining services. It uses scala traits to define services, classes to implement services, and constructors to define service dependencies. Finally, it lifts the class constructor into the ZLayer.
-
Service Definition
Since the service definition uses traits, for this CRUD example, our domain will define the CRUD operations for the entity User and will be defined on the UserRepository trait.
case class User(id: String, name: String, age: Int) trait UserRepository { def save(user: User): Task[String] def findById(id: String): Task[Option[User]] def findAll: Task[List[User]] def delete(id: String): Task[Unit] }
-
Service Implementation
Like the object-oriented fashion, it uses a Scala class to implement the UserRepository trait, for example purposes, it is provided two implementations one InMemory (InMemoryUserRepository) and another using an H2 Database implementation (PersistentUserRepository) using ZIO JDBC. however, InMemory will be used.
import com.thesampleapp.crud.zio.domain.{User, UserRepository} import zio.{Random, Ref, Task, UIO, ZLayer} class InMemoryUserRepository(map: Ref[Map[String, User]]) extends UserRepository{ override def save(user: User): UIO[String] = { for { id <- Random.nextUUID.map(_.toString) _ <- map.updateAndGet(_ + (id -> user)) }yield id } override def findById(id: String): UIO[Option[User]] = map.get.map(_.get(id)) override def findAll: UIO[List[User]] = map.get.map(_.values.toList) override def delete(id: String): Task[Unit] = map.get.map(_.removed(id)) }
import com.thesampleapp.crud.zio.domain.{User, UserRepository} import io.getquill.jdbczio.Quill import io.getquill.{Escape, H2ZioJdbcContext} import zio.{Random, Task, ZLayer} import java.util.UUID import javax.sql.DataSource case class UserTable(uuid: UUID, name: String, age: Int){ def toDomain: User = User(id = uuid.toString, name = name, age = age) } class PersistentUserRepository(ds: DataSource) extends UserRepository{ val ctx = new H2ZioJdbcContext(Escape) import ctx._ override def save(user: User): Task[String] = { for { id <- Random.nextUUID _ <- ctx.run { quote { query[UserTable].insertValue { lift(UserTable(id, user.name, user.age)) } } } }yield id.toString }.provide(ZLayer.succeed(ds)) override def findById(id: String): Task[Option[User]] = ctx .run { quote { query[UserTable] .filter(p => p.uuid == lift(UUID.fromString(id))) .map(u => u.toDomain) } } .provide(ZLayer.succeed(ds)) .map(_.headOption) override def findAll: Task[List[User]] = ctx .run { quote { query[UserTable].map(u => u.toDomain) } } .provide(ZLayer.succeed(ds)) override def delete(id: String): Task[Unit] = {for{ _ <- ctx.run{ quote { query[UserTable].filter(u=> u.uuid.toString == id).delete } } }yield ()}.provide(ZLayer.succeed(ds)) }
-
ZLayer (Constructor)
Now, we create a companion object for the implementation this will allow the dependency injection to provide the instance of this service
object PersistentUserRepository { def layer: ZLayer[Any, Throwable, PersistentUserRepository] = Quill.DataSource.fromPrefix("UserApp") >>> ZLayer.fromFunction(new PersistentUserRepository(_)) }
-
Accessor Methods
Finally, to create the API more ergonomic, it's better to write accessor methods for all of our service methods using ZIO.serviceWithZIO constructor inside the companion object:
object UserRepository{ def save(user: User): ZIO[UserRepository, Throwable, String] = ZIO.serviceWithZIO[UserRepository](_.save(user)) def findById(id: String): ZIO[UserRepository, Throwable, Option[User]] = ZIO.serviceWithZIO[UserRepository](_.findById(id)) def findAll: ZIO[UserRepository, Throwable, List[User]] = ZIO.serviceWithZIO[UserRepository](_.findAll) def delete(id: String): ZIO[UserRepository, Throwable, Unit] = ZIO.serviceWithZIO[UserRepository](_.delete(id)) }
4. HTTP Routes (Rest integration)
ZIO also provides ZIO HTTP, which allows exposing over HTTP the API that has been defined and implemented.
import com.thesampleapp.crud.zio.domain.{User, UserRepository}
import zio.ZIO
import zio.http._
import zio.http.model.{Method, Status}
import zio.json._
object UserRoutes {
def apply(): Http[UserRepository, Nothing, Request, Response] = Http.collectZIO[Request] {
case Method.GET -> !! / "users" => UserRepository.
findAll.map(response => Response.json(response.toJson)).orDie
case Method.GET -> !! / "users"/ userId =>
UserRepository.findById(userId).map{
_ match {
case Some(user) => Response.json(user.toJson)
case None => Response.status(Status.NotFound)
}
}.orDie
case req @ Method.POST -> !! / "users" => {
for{
user <- req.body.asString.map(_.fromJson[User])
r <- user match{
case Left(e) =>
ZIO
.debug(s"Failed to parse the input: $e")
.as(
Response.text(e).setStatus(Status.BadRequest)
)
case Right(u) =>
UserRepository
.save(u)
.map(id => Response.text(id))
}
} yield r}.orDie
case Method.DELETE -> !! / "users"/ userId => {
for{
_ <- UserRepository.delete(userId)
}yield ()
}.map(_ => Response.status(Status.NoContent)).orDie
}
}
5. ZIO Application
Finally, the ZIO APP allows the start of the ZIO application and configuration of the application, here the routes and services are being provided using the ZIOAppDefault, by the overriding run method.
import com.thesampleapp.crud.zio.http.UserRoutes
import com.thesampleapp.crud.zio.infra.InMemoryUserRepository
import zio._
import zio.http._
object ZioApp extends ZIOAppDefault{
override val run =
Server.serve(UserRoutes()).provide(Server.default,InMemoryUserRepository.layer)
}