Akka vs ZIO: Framework Battle
There are excellent functional programming libraries for building asynchronous applications within Scala. Each of these libraries are offering unique features and approaches, also providing the developers a wide range of choices to suit their specific needs. Akka and ZIO are two powerful ecosystems that offer distinct approaches for building concurrent and resilient applications.
At ING we use reactive programming and distributed systems to ensure each service uses an asynchronous and non-blocking model. In this article, we will explore the differences between Akka-http and ZIO-Http as well as Actor model versus ZIO. The example used here is a Rest API written in Scala with a simple functionality and is built on top of Akka HTTP and the Actor model. We will highlight the key features and design philosophies of both frameworks throughout our example. We will be using the following versions of projects:
- ZIO: 2.0.13
- ZIO-HTTP: 2.0.0-RC10
For those interested in directly accessing the code, the complete source code is available in my GitHub repository.
Akka Actors
Akka, a battle-tested actor-based framework, has long been the go-to choice for building highly concurrent and fault-tolerant applications. Its actor model provides a powerful abstraction for managing concurrency and scalability. In an actor model the focus is on the concept of actor. An actor represents a separate computational unit in a system which interacts with the other actors using a built-in messaging mechanism.
In our example used here, we have an actor system with one actor, RegisterOrderActor, with a simple functionality of registering orders. The registerOrderActor handles two types of messages GetOrders for retrieving all available orders and SubmitOrder for submitting an order. We have two Akka-http routes to handle these messages.
The receive function of RegisterOrderActor would be as following:
def receive: Receive = {
case GetOrders() =>
sender() ! Orders(orderMap.values.toList)
case SubmitOrder(order) =>
log.info("registering the order")
val id = java.util.UUID.randomUUID.toString
orderMap += (id -> order)
sender() ! ActionExecuted(s"OrderID ${id} submitted.")
}
Akka HTTP
Akka HTTP is a powerful and widely-used open-source library in the Akka toolkit, which provides a flexible and efficient toolkit for building web services and APIs in a reactive and asynchronous manner. Routing in Akka HTTP is done using a routing DSL that allows developers to define the behavior of HTTP endpoints and handle incoming requests.
Server initialization
Initializing the server in Akka HTTP is straightforward. We first create an implicit ActorSystem, which is required for running the server. Then, we create an implicit execution context and an actor materializer, which are needed for handling the asynchronous operations and stream processing in Akka HTTP.
object WebServer extends App with OrderRoutes {
implicit val system = ActorSystem("OrdersActorSystem")
implicit lazy val exec: ExecutionContext = system.dispatcher
implicit val materializer = ActorMaterializer()
val registerOrderActor: ActorRef = system.actorOf(RegisterOrderActor.props, "registerOrderActor")
lazy val routes: Route = serviceRoute
Http().bindAndHandle(routes, "localhost", 9001)
Await.result(system.whenTerminated, Duration.Inf)
}
Routes
Routes in Akka HTTP are typically defined using the Routing DSL (Domain-Specific Language). The overview of routing is as following:
- Defining Routes: Routes in Akka HTTP are defined by creating a Route object, which represents the behavior of a particular HTTP endpoint. Routes can be created using a combination of directives and combinators provided by the Akka HTTP library.
- Directives: Directives in Akka HTTP are building blocks used to define routing logic. They provide a way to match and extract information from incoming requests and specify how to handle those requests. Directives can be combined using operators to create complex routing structures.
Below is an example of how two order routes are defined in Akka-Http:
val getOrdersRoute: Route = path("orders") {
pathEnd {
get {
val orders: Future[Orders] =
(registerOrderActor ? GetOrders()).mapTo[Orders]
complete(orders)
}
}
}
val submitOrderRoute: Route = path("order") {
pathEnd {
post {
entity(as[Order]) { order =>
val orderCreated: Future[ActionExecuted] =
(registerOrderActor ? SubmitOrder(order)).mapTo[ActionExecuted]
onSuccess(orderCreated) { executedAction =>
complete((StatusCodes.Created, executedAction))
}
}
}
}
}
val serviceRoute: Route = getOrdersRoute ~ submitOrderRoute
ZIO
ZIO library stands out as a game-changer, offering a powerful ecosystem for building robust concurrent applications. At the core of ZIO lie its composable types, which provide a solid foundation for creating high-quality software solutions. In functional programming, managing side effects is often considered a challenge. ZIO Effects offer a type-safe and composable approach for handling side effects. In other words, ZIO effect represents a computation that encapsulates side effects and is expressed as a value of type ZIO[R, E, A] which is explained in the next section.
ZIO types
ZIO provides a rich set of data types which enable developers to build robust and composable applications. Each type serves a specific purpose and contributes to the overall expressiveness and flexibility of building concurrent and error-handling applications with ZIO. Here are some key ZIO data types:
- ZIO[R, E, A]: This is the core data type of ZIO. It represents an effect that can succeed with a value of type A, fail with an error of type E, and depends on an environment of type R. It encapsulates the entire lifecycle of an effect, from its dependencies to its potential failures and final output.
- UIO[A]: UIO, short for “uninterruptible IO,” represents a ZIO effect that will not be interrupted due to an asynchronous interruption signal. It is commonly used for effects that do not perform blocking or interruptible operations.
- IO[E, A]: This is a type alias for ZIO[Any, E, A], representing an effect that can fail with an error of type E. It is a generic type commonly used when the effect doesn’t have any specific dependencies.
- Task[A]: This is a type alias for ZIO[Any, Throwable, A], representing a ZIO effect that can fail with a Throwable error. Task is typically used for encapsulating computations that may have exceptions.
- RIO[R, A]: RIO, short for “Reader IO,” represents a ZIO effect that depends on an environment of type R and produces a value of type A. It allows accessing the environment within the ZIO effect.
- URIO[R, A]: URIO, short for “uninterruptible Reader IO,” is similar to RIO but guarantees uninterruptibility, making it useful for effects that should not be interrupted.
- ZLayer[RIn, E, ROut]: ZLayer represents a layer of dependencies within ZIO. It allows you to define and compose modules that provide specific dependencies to your ZIO effects. ZLayers can be combined and transformed, enabling powerful dependency injection capabilities.
ZIO HTTP
ZIO HTTP is an impactful library for constructing high-performance HTTP services and clients using functional Scala and ZIO. It leverages the power of Netty as its core for efficient networking.
Server initialization
Server initialization in ZIO http is as straightforward as Akka http. Below you can see the code snippet that initializes our web server using ZIO. The `run` method returns a ZIO effect which represents the main logic of the server. It starts by invoking the `Server.start` method, which starts the server on port 9001 and specifies the `httpApp` to handle HTTP requests. The `server.provide` method is used to provide the required dependencies to our ZIO HTTP server. By providing `OrderServiceImp.live` using `server.provide`, we are making sure that the ZIO HTTP server has access to the live implementation of the `OrderService` when it needs it. In the next section, we will explore the details of the `OrderService` implementation.
object WebServer extends ZIOAppDefault with OrderRoutes {
def run: ZIO[Environment with ZIOAppArgs with Scope, Any, Any] =
Server
.start(
port = 9001,
http = httpApp
)
.provide(
OrderServiceImp.live
)
}
The `httpApp` is a function that maps incoming HTTP requests to corresponding ZIO effects, which handle the requests and generate appropriate responses. The code snippet below showcases our `httpApp` implementation.
val httpApp: Http[OrderService, Throwable, Request, Response] = Http.collectZIO[Request] {
case Method.GET -> !! / "orders" =>
OrderService.getOrders.map(response => Response.json(response.toJson))
case req @ Method.POST -> !! / "order" => for {
order <- req.bodyAsString.map(_.fromJson[Order])
response <- order match {
case Left(error) =>
ZIO
.debug(s"Failed to parse the input: $error")
.as(Response.text(error).setStatus(Status.BadRequest))
case Right(order) =>
OrderService.submitOrder(order)
.map(id => Response.text(s"order ${id} is created")
.setStatus(Status.Created))
}
} yield response
}
- When a GET request is received for the “/orders” path, it invokes the `OrderService.getOrders` method, which retrieves a list of orders. The response is then converted to JSON format using `.toJson`, and the resulting JSON is used to construct a HTTP response of type `Response.json`.
- When a POST request is received for the “/order” path, it performs a series of operations. First, it extracts the request body as a string using `req.bodyAsString`. Then, it attempts to parse the request body into an `Order` object using `.fromJson[Order]`. After parsing the request body, it performs pattern matching on the result. If the parsing was successful (`Right(order)`), it invokes `OrderService.submitOrder(order)`, which processes the order and result in order ID which is used to construct an HTTP response of type `Response.text`. If the parsing failed (`Left(error)`), it logs an error message using ZIO.debug.
Service Implementation
For service implementation, ZIO 2.0 enforces us to use the Service Pattern which is a design pattern used to define and manage dependencies between services in a functional and modular way. It helps in decoupling services from their implementations and provides a flexible and testable structure for building applications. Let’s explore the different components of the service pattern in our `Order Service`:
- Service Definition: Services are defined using Scala traits. These traits declare the functionality and operations that the service provides. They act as interfaces or contracts, specifying the methods and types that the service should support. Below OrderService, declares two methods:
trait OrderService {
def getOrders(): Task[Orders]
def submitOrder(order: Order): Task[String]
}
2. Service Implementation: Service implementations are created as classes that extend the corresponding service trait. These classes provide the actual implementation logic for the declared service methods. By separating the definition from the implementation, it allows for different implementations to be easily swapped out and managed. The `OrderServiceImp` class implements the `getOrders()` method to retrieve orders and the `submitOrder(order: Order)` method to submit an order which in this case it is updating the mutable `orderMap`.
case class OrderServiceImp(orderMap: Ref[mutable.Map[String, Order]]) extends OrderService {
override def getOrders(): UIO[Orders] = orderMap.get.map(_.values.toList).map(l => Orders(l))
override def submitOrder(order: Order): UIO[String] =
for {
id <- Random.nextUUID.map(_.toString)
_ <- orderMap.updateAndGet(_ addOne(id, order))
_ <- ZIO.log(s"OrderID ${id} processed.")
} yield id
}
3. Service Dependencies: Services may have dependencies on other services or external resources. These dependencies are typically defined as constructor parameters in the service implementation classes. By explicitly stating dependencies in the constructor, it makes it clear what resources are required for the service to function correctly. In our example the `OrderServiceImp` does not depends on other services.
4. Service Layer: The ZIO library provides the `Zlayer` type which represents the layer of abstraction that encapsulates the service implementation and exposes the service interface. It acts as a boundary between the consumers of the service and the internal implementation details. It allows us to compose and layer services together. In the below code snippet, the `live` value represents a `ZLayer` that provides an implementation of the `OrderService` interface. It initializes a mutable map using `Ref.make`, creates an instance of `OrderServiceImp` by passing the map as a parameter, and wraps it in a ZIO effect using `ZLayer.fromZIO`. This layer can be composed with other layers and used for dependency injection when constructing services that depend on the `OrderService` interface.
object OrderServiceImp {
val live: ZLayer[Any, Nothing, OrderService] = ZLayer.fromZIO(
Ref.make(mutable.Map.empty[String, Order]).map(new OrderServiceImp(_))
)
}
5. Accessor Methods: Accessor methods provide a convenient way to leverage the full functionality of a service within the ZIO Environment. By using accessor methods, we can obtain an instance of the service within a ZIO effect without explicitly passing it as a parameter. The `ZIO.serviceWithZIO` is an accessor method which helps us to access the corresponding service methods within the ZIO effects by passing the appropriate function that invokes those methods. The below code snippet represents the companion object `OrderService` with two methods of `getOrders `and `submitOrder` returning ZIO effects. These effects require an instance of `OrderService` as a dependency and can potentially raise `Throwable` errors.
object OrderService {
def getOrders(): ZIO[OrderService, Throwable, Orders] = ZIO.serviceWithZIO[OrderService](_.getOrders())
def submitOrder(order: Order): ZIO[OrderService, Throwable, String] = ZIO.serviceWithZIO[OrderService](_.submitOrder(order))
}
Comparison of Akka and ZIO
Comparing Akka Actors and ZIO to determine which one is better is not a straightforward task, as both frameworks have their own strengths and considerations.
Akka Actors have been around for a long time and are industry standards. Akka Actors provide a powerful concurrency model that allows for efficient utilization of resources and scalability. Furthermore, it has a rich ecosystem with a variety of extensions and libraries that can complement the development process. However, designing and managing the hierarchy of actors in a large-scale system can be complex and requires careful planning. In addition, considering Lightbend’s announcement in September 2022 regarding the migration of the Akka license from the commercially friendly Apache 2 license to the Business Source License, could have a significant cost on the utilization of Akka.
ZIO is a functional programming library that embraces immutability and has a lightweight design that allows composition of effects, making it simple to build complex applications. In addition, ZIO provides a robust and composable error handling mechanism, enabling developers to handle errors in a structured and consistent way. Nevertheless, compared to Akka, ZIO has a smaller ecosystem and fewer available libraries, although it is growing rapidly.
The learning curve for both Akka and ZIO might be complex depends on the background of the developer. Working with Akka Actors can be complex for developers who are not familiar with the Actor model or asynchronous programming paradigms. On the other hand, the functional programming concepts in ZIO can be unfamiliar to developers coming from an imperative or object-oriented background. Also, working with asynchronous operations in ZIO for developers new to the paradigm, may require additional effort and understanding.
Ultimately, the choice between Akka Actors and ZIO depends on the specific use case, team expertise, and project requirements.
Summary
In this article, we delve into a comprehensive comparison of two popular frameworks: Akka and ZIO. The aim is to provide insights for developers seeking the most suitable framework for their projects. We begin by introducing the two frameworks, highlighting their key features and design philosophies, then we proceed with an implementation example for each framework. The implementation example showcases an application that is initially developed using Akka, and later migrated to ZIO.
Throughout the article, an analysis of the pros and cons of each framework is presented. Akka Actors and Akka HTTP are praised for their maturity, extensive ecosystem, and fault tolerance, while ZIO and ZIO HTTP are admired for their functional programming approach, lightweight design, and robust error handling.
The article concludes by highlighting key factors to consider, such as the learning curve, ecosystem size, team expertise, and project requirements. It encourages developers to assess their specific needs and goals before selecting either framework.