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:
- Different actors may handle requests and responses.
- 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:
- 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
}
}
}
- 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.