Rethinking Message Definition in Akka Typed

Introduction:
Defining messages within an actor’s companion object is a prevalent practice in Akka Typed. However, a closer examination reveals that many protocols, or message interfaces, involve interactions from multiple actors. Especially when considering that requests and responses are typically processed by different actors, and that different actors can also have interest in the same events emitted by commands. Hence, I believe there’s a need for a more intuitive approach to message definition.

The Problem: In the actor model:

  1. Different actors may handle requests and responses.
  2. Multiple actors can subscribe to events emitted as a result of commands.

Given this, the encapsulation of all related messages within a single actor’s companion object can be misleading.

The Proposed Solution:

Introducing GreeterService:

object GreeterService {
  object Command {
    enum Request extends Message.Command.Request:
      case Greet(whom: String, replyTo: ActorRef[Response.Greet], notifyTo: ActorRef[Event.Greeted])

    enum Response extends Message.Command.Response:
      case Greet(from: ActorRef[Request.Greet])
  }

  enum Event extends Message.Event:
    case Greeted(whom: String)
}

Actors using GreeterService interface:

  1. GreeterServiceActor - processes greeting requests:
object GreeterServiceActor extends Actor[GreeterServiceActor.Message] {
  type Message = GreeterService.Command.Request
  
  def onMessage(context: ActorContext[Message], message: Message): Behavior[Message] = {
    message match {
      case GreeterService.Command.Request.Greet(whom, replyTo, notifyTo) =>
        context.log.info(s"Hello $whom")
        replyTo ! GreeterService.Command.Response.Greet(context.self)
        notifyTo ! GreeterService.Event.Greeted(whom)
        Behaviors.same
    }
  }
}
  1. GreeterServiceClientAndConsumer - handling greeting responses and processing events:
object GreeterServiceClientAndConsumer extends Actor[GreeterServiceClientAndConsumer.Message] {
  type Message = GreeterService.Command.Response | GreeterService.Event
  
    override def onMessage(context: ActorContext[Message], message: Message): Behavior[Message] = {
    handleMessage(0, context, message)
  }

  private def handleMessage(n: Int, context: ActorContext[Message], message: Message): Behavior[Message] = {
    message match {
      case GreeterService.Event.Greeted(whom) => {
        context.log.info(s"Greeting $n is for $whom")
        behavior(n + 1)
      }
      case GreeterService.Command.Response.Greet(from) => {
        context.log.info(s"Response to command Greet received from $from")
        Behaviors.same
      }
    }
  }

  private def behavior(n: Int): Behavior[Message] = Behaviors.receive {
    (context: ActorContext[Message], message: Message) => {
      handleMessage(n, context, message)
    }
  }

Advantages:

  • Clarity of Intention: By defining the protocol independently of any specific actor, we underline that a protocol, or message interface, can involve many different actors.
  • Exhaustiveness Checking: the use of unions of sealed hierarchies still enable the Scala compiler to check for exhaustiveness in pattern matching, making code safer.
  • Encapsulation: This method makes it more clear which messages an actor processes, preserving the encapsulation principle and still ensuring that actors only receive messages meant for them.
  • Discoverability: With this approach, it’s still easy to understand what messages are part of a given protocol.

Conclusion: While Akka Typed provides foundational guidelines, adapting practices to capture the genuine interactions of actors in systems is paramount. The solution discussed offers a clear depiction of actor interactions, enhancing both code readability and developer comprehension.

1 Like

Defining the protocol separately from the actor implementations, but in the same module, can make sense in some cases, where it is important that a consumer of a protocol needs to know several actors are involved, for example to directly interact with them.

In our experience the vast majority of actor protocols does not look like that but are more like an interface in the object oriented world, defining how to interact with one actor, the actor owning that protocol. Even when multiple actors are sharing the protocol, it is most often an internal implementation detail, such as a work manager delegating to worker children, where a consumer should not need to know about that.

1 Like