A tour of ZIO


There are lot of libraries that makes it easy to develop concurrent applications on the JVM, most notably Akka that uses the Actor model.

In fact, Akka actors can be used to solve a lot of challenges, but they also have high implications:

Alternatively to Akka, other libraries provides concurrency primitives that can be used to achieve similar functionality. For instance ZIO/Cats Effect, which in addition to make developing concurrent applications easy and because they are purely functional they also provide improved type safety, immutability, and purity.

Here are some interesting talks about moving away from Akka Actor to more functional alternatives:

Choosing between ZIO and Cats Effect depends on your taste of functional programing. For more details on the comparison between the two libraries you can check the following Redit thread about evolving to ZIO or Cats Effects - link.

In Short:

In the rest of this article we will focus on ZIO and the features it provides:

Modularity with ZIO

One of the big advantages of ZIO compared to Cats Effect is the support of Modularity which is an important Object Oriented Paradigm. ZIO allows the creation of modular code thanks what’s called ZLayer which can be composed, have dependencies which can be injected by specific implementations.

For example to create a ZLayer out of a simple service, we first create an interface of the API exposed by the service and provide an implementation as follows:

// define service
trait ServiceA {
  def process(input: String): IO[ErrorType, OutputType]
}
// implement service
final case class ServiceAImpl() extends ServiceA {
  def process(input: String): IO[ErrorType, OutputType] =  // business logic here
}

Then we create a ZLayer of the interface that uses the implementation like this

object ServiceAImpl {
  val layer: ULayer[Has[ServiceA]] = (ServiceAImpl.apply _).toLayer
}

Notice how we are lifting the Service implementation into a ZLayer using the toLayer method.

Here is a more complex example of a service ServiceC that depends on other services ServiceA and ServiceB

// define service
trait ServiceC {
  def process(input: String): IO[ErrorType, OutputType]
}
// implement service C that depends on Service A and B
final case class ServiceCImpl(a: ServiceA, b: ServiceB) extends ServiceC {
  def process(input: String): IO[ErrorType, OutputType] =  // business logic here
}

The we lift the service implementation to a ZLayer as follows:

object ServiceCImpl {
  val layer: URLayer[Has[ServiceA] with Has[ServiceB], Has[ServiceC]] = (ServiceCImpl(_, _)).toLayer
}

We can simplify the use of the service by creating some helpers that create ZIO services

// How to use the services to create a ZIO effect
object ServiceC {
  def processWithA(input: String): ZIO[Has[ServiceA], ErrorType, OutputType] = ZIO.serviceWith[ServiceA](_.parse(input))
  def processWithC(input: String): ZIO[Has[ServiceC], ErrorType, OutputType] = ZIO.serviceWith[ServiceC](_.parse(input))
}

Note: this code snippet uses ZIO version 1.x, in ZIO version 2.x this is simplified.

Synchronous / Asynchronous with ZIO effects

ZIO effect are all about Asynchronous (non-blocking) logic which is the basis of concurrency. But ZIO effects can also wrap synchronous (blocking) code so that it runs it on a dedicated thread pool. Here are some examples of making ZIO effect out of blocking or non-blocking code:

Synchronous code can be converted into a ZIO effect using ZIO.attempt:

val readLine: ZIO[Any, Throwable, String] = ZIO.attempt(StdIn.readLine())

ZIO has a blocking thread pool built into the runtime, and To execute effects there with ZIO.blocking or:

val sleeping = ZIO.attemptBlocking(Thread.sleep(Long.MaxValue))

Asynchronous code that exposes a callback-based API can be converted into a ZIO effect using ZIO.async:

object legacy {
  def login(onSuccess: User => Unit, onFailure: AuthError => Unit): Unit = ???
}
val login: ZIO[Any, AuthError, User] = ZIO.async[Any, AuthError, User] { callback =>
  legacy.login( user => callback(ZIO.succeed(user)), err  => callback(ZIO.fail(err)) )
}

For more examples check the documentation - link

Concurrency with ZIO fibers

With ZIO, creating asynchronous and concurrent code becomes an easy busiess. At its core, the concurrency in ZIO is based on the Join-Fork pattern. Furthermore, for efficiency ZIO does not uses Threads but instead uses Fibers which are lighter and more efficient than Threads.

Here is an example of concurrency with fork and join which returns the fiber success/fail

for {
  fiber   <- ZIO.succeed("Hi!").fork // forking an effect creates a fiber from current one
  message <- fiber.join // join this fiber with main one
} yield message

Here is another exmaple of concurrency with fork and await which returns Exit value (information on how the fiber completed)

for {
  fiber   <- ZIO.succeed("Hi!").fork // forking an effect creates a fiber from current one
  exit    <- fiber.await // join this fiber with main one
} yield exit

For more examples check the documentation - link.

To learn more about fibers and project loom which introduced them check this article - link.

Resources with ZIO

Interacting with external services (e.g. Databases) is handled in ZIO with what is called Resources which were handled differently between version 1 and version of 2 of ZIO.

Old way with ZManaged

In ZIO version 1, resources were wrapped with in a ZManaged type. For instance, the following example shows how to manage File resources ZManaged:

def file(name: String): ZManaged[Any, Throwable, File] = ???
file(name).use { file =>
 ???
}

Similarly to any other ZIO concept, we can compose ZManaged resources as follows:

for {
 file1 <- file(path1)
 file2 <- file(path2)
} yield (file1, file2)

New way with Scope

In verion 2 of ZIO, the type ZManaged was removed and managing resources becomes much easier thanks to ZIO scopes.

Here is an example of how to manage resources using dynamic Scopes:

def file(path: String): ZIO[Scope, Throwable, File] = ???
ZIO.scoped {
 file(path).flatMap(useFile)
}

Because Resources are simply ZIO effect, we can now compose them like we compose any other ZIO effect as follows:

for {
 file1 <- file(path1)
 file2 <- file(path2)
} yield (file1, file2)

To learn more about how Scopes replaced ZManged check this video and this article

ZIO SQL

ZIO SQL is a relatively new library that provides a ZIO way for connecting and interacting with databasses

Here are some examples of using ZIO SQL to perform different SQL operations

// inserting into a table
insertInto(persons)(id ++ name ++ age).values(List((1, "Charles", 30), (2, "Martin", 28), (3, "Harvey", 42)))

// joining two tables
select(firstName ++ orderDate).from(customers.join(orders).on(id === customerId))

// selecting with subquery
val subquery = customers.subselect(Count(orderId)).from(orders).where(customerId === id)
val query = select(fName ++ lName ++ (subquery as "Count")).from(customers)

Note: For now it seems that ZIO SQL supports only PostgresSQL as a database.

You can learn more about ZIO SQL in this video - link. Another intersting library with ZIO support is Quill, you can check about how it integrates with ZIO here - link.

References

Here is a non-exhaustive list of resources to learn more about ZIO and other frameworks for building concurrent applications on the JVM: