Akka typed

Arguably, the major criticism made about akka is the fact that the receive function can accept anything and return nothing (PartialFunction[Any, Unit]). It means that traditional Akka is effectively untyped. Akka-typed is intended to solve this particular problem by enforcing the types of incoming and outgoing messages. It does that by mandating us to define a new kind of data: Behaviors. But what are behaviors good for? We can think of behaviors as our communication protocol. In fact, with akka-typed, we are in charge of the protocol and actors do the actual work under the hood. This article assumes that you have at least a basic level of familiarity with Akka and the Scala programming language.

Hello actor! It is time to do the mandatory

Hello world application. First, let’s see the traditional akka-untyped approach:

class HelloActor extends Actor {
  def receive(): Receive = {
    case SayHello(name) =>
      println(s"Hello $name!")
  }
}

object HelloActor {
  final case class SayHello(name: String)
}

It is time for akka-typed. As mentioned before, we need to build Behaviors. In its simplest form, a Behavior requires the following elements:

object TypedHello {
 // 1. protocol
 sealed trait Command
 final case class SayHello(name: String) extends Command

 // 2. behavior
 def apply(): Behavior[Command] = {
  Actor.immutable {
   case (context, SayHello(name)) =>
    println(s"Hello $name!")
    Actor.same // same behavior
   }
  }
}

Wait, what? No receive function? No Actor trait? Worry not!, we will guide you through this new way of implementing your actors with a more complete example, prime calculation. Remember that all the source code is available in the repository for this article.

Eratosthenes prime calculation

The Sieve of Eratosthenes is an algorithm for calculating prime numbers. It goes as follows:

  1. Create a list, l of the consecutive numbers from 2 to n
  2. Assume the first number is a prime
  3. Take the first number of the list, h
  4. Remove from l the numbers that are multiples of h
  5. Add h to the result
  6. Back to step 2.

Using an actor system, we choose to design it using the following actors and message exchanges:

Prime Finder actor
Prime Finder actor

This actor is responsible for starting the prime calculation. We can think of it as a façade to the whole system. This actor does the following:

The akka-typed way of creating actors is based on the spawn function added to the ActorContext through the implicit class UntypedActorContextOps present in akka.typed.scaladsl.adapter. Now, we get a typed ActorRef! So, in our example, master is actually an ActorRef[MasterProtocol]. It is important to notice that the concept of props is gone. With akka-typed, only one function containing the behavior is enough to create an actor.

// an untyped-actor
class PrimeFinder extends Actor with ActorLogging {
 import akka.typed.scaladsl.adapter._

 // creating the typed actor
 val master: ActorRef[MasterProtocol] = context.spawn(Master(), name = Master.name)

 override def receive: Receive = {
  case Start(upper) =>
   if (upper < 2) sender ! Nil
   else master ! Master.Messages.FindPrimes(upper, sender())
 }
}

Not much changed here.

Master actor

This actor is our first truly typed actor. It is responsible for:

Communication protocol

Let’s define our protocol accordingly:

object Messages {

 sealed trait MasterProtocol

 final case class FindPrimes(
   upper: Int,
   client: ActorRef[Any]
 ) extends MasterProtocol

 final case class Result(
   list: List[Int]
 ) extends MasterProtocol

}

Handling messages

Before getting into the details, it is necessary to clearly identify the elements of the akka-typed Domain Specific Language (DSL) we are going to handle. First, we need to keep in mind that protocols are usually defined as sealed traits (also known as a Sum Types or sealed family) because the compiler can warn us when we are mistakenly sending and receiving messages that do not belong to the protocol. Our typed actor needs a function to construct its behavior using the protocol we created. It just means we need to provide a function returning a Behavior[MasterProtocol]:

object Master {
  def apply(): Behavior[MasterProtocol] = {...}
}

To build the behavior, the akka-typed DSL provides us with the Actor.deferred function which is defined as ActorContext[T] => Behavior[T]. We can think of it as a replacement of the code we used to have in preStart(...) or even in our actor’s constructor body. As such, it is useful in operations that involve the context previous to any message (e.g. creating actors, scheduling calls, etc).

Actor.deferred { context =>
 // creating the typed actor
 val eratosthenesRef: ActorRef[EratosthenesProtocol] =
         context.spawn(Eratosthenes(), name = Eratosthenes.name)
 ...
}

To handle messages there is another useful function called Actor.immutable. It is defined as (ActorContext[T], T) => Behavior[T]). It implies that in akka-typed we don’t receive only our message, but a tuple containing the context and our actual message. We can think of this function as the replacement of the previous receive: Receive function. It is time to actually handle the messages of our protocol using Actor.immutable. As it was stated before, we need to handle:

As the state is not changing, we need to inform the system that there is no problem in reusing the previous state. The function Actor.same does exactly that.

One alternative could be as follows:

def handleMessagesAndReplyTo(
 clientRef: Option[ActorRef[List[Int]]]
): Behavior[MasterProtocol] = {
 Actor.immutable {
 case (_, FindPrimes(upper, client)) =>
   // send the sieve message to the eratosthenes actor
   eratosthenesRef ! Sieve(
                     self, // a reference to this actor
                     Nil, // current prime numbers
                     (2 to upper).toList // the whole list of numbers
                     )
   handleMessagesAndReplyTo(Some(client))
   case (_, Result(list)) =>
     // return the list of primes to grandpa (PrimeFinder actor)
     clientRef.foreach(_ ! list)
     Actor.same
 }
}

Notice how there is no become/unbecome. All that is needed a call to handleMessagesAndReplyTo with the actual actorRef that accepts the result. Assembling the pieces together gives this code:

object Master {
 def apply(): Behavior[MasterProtocol] = {
   Actor.deferred { context =>
    val eratosthenesRef: ActorRef[EratosthenesProtocol]
        = context.spawn(Eratosthenes(), name = Eratosthenes.name)

       val self = context.self

       def handleMessagesAndReplyTo(
        clientRef: Option[ActorRef[List[Int]]]
       ): Behavior[MasterProtocol] = {
         Actor.immutable {
           case (_, FindPrimes(upper, client)) =>
            eratosthenesRef ! Eratosthenes.Messages.Sieve(
              self, Nil, (2 to upper).toList)
            handleMessagesAndReplyTo(Some(client))

            case (_, Result(list)) =>
             clientRef.foreach(_ ! list)
             Actor.same
          }
         }
      handleMessagesAndReplyTo(None)
 }
}

Eratosthenes actor

As usual, we first need to define our protocol. In this case it is quite easy since we only need to handle a single Sieve message containing:

object Messages {

   final case class Sieve(
      master: ActorRef[MasterProtocol],
      primes: List[Int],
      remaining: List[Int]
   )
 }

OK, let’s implement our behavior. Since there’s no need to do anything before receiving the actual message, then our behavior goes in a Actor.immutable. For this actor, we can handle messages similar to what we did with Master. However, we cannot ignore the first element in the tuple (ActorContext[T], ActualMessage) because we will need context to send us back the Sieve message to continue with the next iteration. Then, because the state doesn’t change, we just return the same state through Actor.same.

Actor.immutable {
  case (context, Sieve(parent, primes, remaining)) =>
  //If no remaining numbers
  // parent ! Master.Messages.Result(primes)
  //Else
  // context.self ! Sieve(/* new list of primes */)
 Actor.same
}

Now that we have all the assembling pieces, the whole code for this actor goes like this:

object Eratosthenes {
 def apply(): Behavior[Sieve] = {
   Actor.immutable {
     case (context, Sieve(parent, primes, remaining)) =>
       remaining match {
         case Nil =>
           parent ! Master.Messages.Result(primes)
           Actor.same

         case h :: tail =>
           context.self ! Sieve(
              parent,
              primes ++ List(h),
              tail.filter(notMultiplesOf(h))
           )
           Actor.same
        }
     }
 }
 def notMultiplesOf(h: Int)(x: Int): Boolean = x % h != 0
}

Final notes

We covered an example showing the main changes coming with akka-typed. We explained how to interact with untyped actors and how to create Behaviors. As you might have noticed, akka-typed brings fundamental changes to our actors. However, as we are forced to create and respect our protocol, we are pretty sure that the compiler will warn us in case we are not respecting the Types. We think this new version goes in the good direction: both to enforce types and to simplify our own implementations. We hope you enjoyed. See you next time.