Designing Good Message Protocols

By lesson five, you have already seen why Akka exists, how to think about actors without mythology, how the ecosystem fits together, and what a first useful Akka Typed service can look like. The next step is one of the most important practical skills in the entire platform: designing message protocols well.

This matters more than many teams expect. Akka systems rarely become confusing because Behavior is hard to read. They become confusing because the messages are vague, overloaded, or shaped around the implementation instead of the domain. Once that happens, the actor stops being a clear boundary and turns into a mailbox full of ambiguous instructions.

Good protocols do not make a system magically correct, but they do make it easier to reason about correctness. They help you see what the actor is responsible for, what callers are allowed to ask of it, what outcomes are possible, and how the design can evolve without becoming brittle.

In this lesson, we will focus on message design in realistic backend scenarios such as payments and inventory reservation. The goal is not academic purity. The goal is to write protocols that remain understandable when the workflow grows, when failures start happening, and when more teams need to work with the same system.

Protocol Design Is Really Interface Design Under Concurrency

One useful way to think about actor messages is this: a protocol is the real public interface of the component.

That sounds obvious, but it is easy to forget. In a non-actor codebase, the public interface is usually a set of methods. In Akka, the public interface is the set of messages other components are allowed to send.

That means message design affects several things at once:

  • what operations the actor supports
  • what information callers must provide
  • what invariants the actor can enforce
  • what replies or follow-up messages exist
  • how easy it is to evolve the workflow later

If the protocol is weak, the actor boundary is weak. The actor may still compile, but callers will need tribal knowledge to use it correctly.

That is why protocol design should come before internal implementation details. If you cannot explain the message flow clearly, writing the behavior first usually makes the result worse, not better.

Start From Business Intent, Not Generic Verbs

One of the most common mistakes is to design messages around generic verbs such as Handle, Process, Update, or Execute.

Those names feel flexible, but flexibility is often a warning sign. They hide intent instead of expressing it.

Suppose you are building a payment authorization workflow. This is weak:

sealed trait Command
final case class Process(data: PaymentData, replyTo: ActorRef[Result]) extends Command

What does Process mean?

  • validate card details
  • reserve funds
  • call a payment provider
  • record an audit event
  • retry a previously failed request
  • continue a partially completed workflow

The message name tells us almost nothing.

Now compare that with a protocol shaped around business intent:

import akka.actor.typed.ActorRef

object PaymentAuthorizer {
  sealed trait Command

  final case class AuthorizePayment(
      paymentId: String,
      customerId: String,
      amount: BigDecimal,
      currency: String,
      cardToken: String,
      replyTo: ActorRef[AuthorizationResult]
  ) extends Command

  final case class GetPaymentStatus(
      paymentId: String,
      replyTo: ActorRef[PaymentStatus]
  ) extends Command

  sealed trait AuthorizationResult
  final case class Authorized(paymentId: String, providerRef: String) extends AuthorizationResult
  final case class Declined(paymentId: String, reason: String) extends AuthorizationResult
  final case class ValidationRejected(paymentId: String, reason: String) extends AuthorizationResult

  sealed trait PaymentStatus
  final case class InFlight(paymentId: String) extends PaymentStatus
  final case class Completed(paymentId: String, providerRef: String) extends PaymentStatus
  final case class Failed(paymentId: String, reason: String) extends PaymentStatus
}

This version is already better before we write a single behavior.

The caller can see:

  • the actor authorizes payments
  • the request requires concrete business data
  • authorization outcomes are explicit
  • status queries are a different concern from initiating work

That kind of clarity compounds over time.

Messages Should Be Specific About Required Data

A message protocol should carry the information required for the actor to make a decision, but not hide essential fields inside vague bags of optional data.

This is another common mistake:

final case class PaymentRequest(
    id: String,
    customerId: Option[String],
    amount: Option[BigDecimal],
    currency: Option[String],
    metadata: Map[String, String]
)

This looks generic and reusable. In practice, it pushes business rules into defensive code because the protocol itself no longer says what is required.

Now every handler must keep asking the same questions:

  • is customerId required here?
  • can amount be missing?
  • which metadata keys are mandatory?
  • which combinations are valid for this message?

That is exactly the ambiguity you want to remove.

For actor protocols, it is usually better to make important fields explicit and let invalid requests fail at the boundary as early as possible.

final case class ReserveInventory(
    reservationId: String,
    sku: String,
    quantity: Int,
    warehouseId: String,
    replyTo: ActorRef[ReservationResult]
) extends Command

This is not just about readability. It protects the protocol from turning into a loosely typed mini-language that every caller interprets differently.

Separate Public Messages From Internal Workflow Messages

In the previous lesson, we used internal messages such as validation callbacks and forwarding completions. That distinction is worth making more explicit now.

Most non-trivial actors need two different kinds of messages:

  • public commands that outside callers are allowed to send
  • internal messages that drive the actor's own workflow

Those are not the same thing, and treating them as the same thing often creates trouble.

Consider an inventory reservation actor:

import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.scaladsl.Behaviors

object InventoryReservation {
  sealed trait Command

  final case class Reserve(
      reservationId: String,
      sku: String,
      quantity: Int,
      replyTo: ActorRef[ReservationResult]
  ) extends Command

  final case class GetSnapshot(replyTo: ActorRef[Snapshot]) extends Command

  private final case class StockLookupCompleted(
      reservationId: String,
      sku: String,
      quantity: Int,
      available: Int,
      replyTo: ActorRef[ReservationResult]
  ) extends Command

  private final case class StockLookupFailed(
      reservationId: String,
      reason: String,
      replyTo: ActorRef[ReservationResult]
  ) extends Command

  sealed trait ReservationResult
  final case class Reserved(reservationId: String, sku: String, quantity: Int) extends ReservationResult
  final case class OutOfStock(reservationId: String, sku: String, available: Int) extends ReservationResult
  final case class ReservationFailed(reservationId: String, reason: String) extends ReservationResult

  final case class Snapshot(activeReservations: Int)

  def apply(): Behavior[Command] =
    Behaviors.receiveMessage {
      case GetSnapshot(replyTo) =>
        replyTo ! Snapshot(activeReservations = 0)
        Behaviors.same

      case Reserve(reservationId, sku, quantity, replyTo) =>
        // Start asynchronous stock lookup here
        Behaviors.same

      case StockLookupCompleted(_, _, _, _, _) =>
        Behaviors.same

      case StockLookupFailed(_, _, _) =>
        Behaviors.same
    }
}

The outside world should send Reserve and GetSnapshot. It should not send StockLookupCompleted directly. That is an implementation detail.

Keeping internal messages private has two benefits.

First, it makes the public protocol easier to understand. Second, it prevents callers from depending on workflow details that you may need to change later.

This is one of the easiest protocol-design wins in Akka Typed: keep the externally supported surface area as small and stable as possible.

Commands, Replies, and Domain Events Are Different Things

A protocol becomes much easier to reason about when message types have distinct roles.

In practice, you will often work with at least three categories:

  • commands that ask an actor to do something
  • replies that communicate the result of a request
  • domain events that describe something that happened

These categories are easy to blur together, especially in early versions of a system.

For example, a team may send a message called PaymentAuthorized to an actor intending it as a command, even though the name sounds like an event that already happened. That leads to confusion fast.

A cleaner pattern looks like this:

sealed trait Command
final case class AuthorizePayment(..., replyTo: ActorRef[AuthorizationResult]) extends Command

sealed trait AuthorizationResult
final case class Authorized(paymentId: String, providerRef: String) extends AuthorizationResult
final case class Declined(paymentId: String, reason: String) extends AuthorizationResult

sealed trait DomainEvent
final case class PaymentAuthorizationStarted(paymentId: String) extends DomainEvent
final case class PaymentAuthorizationSucceeded(paymentId: String, providerRef: String) extends DomainEvent
final case class PaymentAuthorizationDeclined(paymentId: String, reason: String) extends DomainEvent

Why bother separating these?

Because the question "what may I send to this actor?" is different from the question "what reply might I get back?" and both are different from "what business event should other parts of the system observe?"

If you collapse those distinctions, protocols become harder to extend and logs become harder to interpret.

Prefer Explicit Outcomes Over Boolean Success Flags

Another recurring mistake is using messages that force the caller to decode meaning from a boolean or from a vague optional field.

For example:

final case class Result(success: Boolean, message: Option[String])

This is a protocol smell. It hides business meaning inside ad hoc conventions.

What does success = false mean?

  • invalid input
  • duplicate request
  • downstream timeout
  • fraud check failure
  • provider outage

The caller now has to inspect a free-form string or infer the meaning from context.

In actor systems, that becomes a maintenance problem quickly because different callers may interpret the same result differently.

A better approach is to model meaningful outcomes directly:

sealed trait ReservationResult

final case class Reserved(
    reservationId: String,
    sku: String,
    quantity: Int
) extends ReservationResult

final case class OutOfStock(
    reservationId: String,
    sku: String,
    available: Int
) extends ReservationResult

final case class InvalidReservation(
    reservationId: String,
    reason: String
) extends ReservationResult

final case class ReservationSystemUnavailable(
    reservationId: String,
    reason: String
) extends ReservationResult

This makes the protocol larger, but it makes the behavior clearer. In production systems, that tradeoff is usually worth it.

Correlation Matters Once Work Stops Being Immediate

As soon as a workflow involves asynchronous collaboration, retries, or multiple in-flight requests, correlation becomes part of protocol design.

If the actor receives an external request, asks another component to do work, and later gets a response, the protocol must make it obvious which request the response belongs to.

That usually means carrying an explicit identifier such as:

  • paymentId
  • reservationId
  • workflowId
  • requestId

Without that, a system under load becomes hard to reason about and hard to debug.

Here is a small example using a payment fraud check:

object FraudCoordinator {
  sealed trait Command

  final case class EvaluatePayment(
      paymentId: String,
      customerId: String,
      amount: BigDecimal,
      replyTo: ActorRef[Decision]
  ) extends Command

  private final case class FraudCheckCompleted(
      paymentId: String,
      riskScore: Int,
      replyTo: ActorRef[Decision]
  ) extends Command

  sealed trait Decision
  final case class Approved(paymentId: String) extends Decision
  final case class ReviewRequired(paymentId: String, riskScore: Int) extends Decision
}

The protocol is telling us that several evaluations may be in progress at once, and completions must correlate back to the original request. That is exactly the kind of information the message shape should make explicit.

Design for Evolution, Not Just the First Release

Message protocols tend to live longer than people expect. Once multiple actors, services, or teams depend on them, changing them gets more expensive.

That does not mean you should over-engineer versioning from day one. It does mean you should avoid designs that are obviously hostile to change.

Some practical habits help:

  • use specific names that will still make sense six months later
  • prefer structured replies over magic strings
  • keep unrelated concerns in separate messages instead of one giant command
  • avoid exposing internal workflow steps as public messages
  • make fields semantically meaningful instead of relying on positional assumptions

Suppose you begin with this:

final case class UpdateOrder(id: String, field: String, value: String)

It feels flexible, but it is hard to evolve safely. The protocol does not express which changes are legal, which values are expected, or what invariants apply.

A protocol shaped around domain actions ages much better:

sealed trait Command
final case class AssignWarehouse(orderId: String, warehouseId: String) extends Command
final case class MarkPacked(orderId: String, packedAt: Instant) extends Command
final case class CancelOrder(orderId: String, reason: String) extends Command

Now the actor's supported operations are explicit. Adding new operations later does not require callers to share undocumented conventions.

A Realistic Protocol Example: Payment Intake With Explicit Boundaries

Let us pull these ideas together in one example. Suppose you are building the first stage of a payment workflow. The actor should accept requests, reject duplicates, trigger validation, and answer status queries.

import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.scaladsl.Behaviors

object PaymentIntake {
  sealed trait Command

  final case class SubmitPayment(
      paymentId: String,
      customerId: String,
      amount: BigDecimal,
      currency: String,
      cardToken: String,
      replyTo: ActorRef[SubmissionResult]
  ) extends Command

  final case class GetStatus(
      paymentId: String,
      replyTo: ActorRef[StatusReply]
  ) extends Command

  private final case class ValidationPassed(
      paymentId: String,
      replyTo: ActorRef[SubmissionResult]
  ) extends Command

  private final case class ValidationFailed(
      paymentId: String,
      reason: String,
      replyTo: ActorRef[SubmissionResult]
  ) extends Command

  sealed trait SubmissionResult
  final case class PaymentAccepted(paymentId: String) extends SubmissionResult
  final case class PaymentRejected(paymentId: String, reason: String) extends SubmissionResult

  sealed trait StatusReply
  final case class PaymentPending(paymentId: String) extends StatusReply
  final case class PaymentValidated(paymentId: String) extends StatusReply
  final case class PaymentUnknown(paymentId: String) extends StatusReply

  private final case class State(validating: Set[String], validated: Set[String])

  def apply(): Behavior[Command] =
    running(State(Set.empty, Set.empty))

  private def running(state: State): Behavior[Command] =
    Behaviors.receiveMessage {
      case SubmitPayment(paymentId, _, amount, currency, _, replyTo) if amount <= 0 =>
        replyTo ! PaymentRejected(paymentId, "Amount must be positive")
        Behaviors.same

      case SubmitPayment(paymentId, _, _, _, _, replyTo)
          if state.validating.contains(paymentId) || state.validated.contains(paymentId) =>
        replyTo ! PaymentRejected(paymentId, "Duplicate payment id")
        Behaviors.same

      case SubmitPayment(paymentId, _, _, currency, _, replyTo) if currency.isBlank =>
        replyTo ! PaymentRejected(paymentId, "Currency is required")
        Behaviors.same

      case SubmitPayment(paymentId, _, _, _, _, _) =>
        running(state.copy(validating = state.validating + paymentId))

      case ValidationPassed(paymentId, replyTo) =>
        replyTo ! PaymentAccepted(paymentId)
        running(
          state.copy(
            validating = state.validating - paymentId,
            validated = state.validated + paymentId
          )
        )

      case ValidationFailed(paymentId, reason, replyTo) =>
        replyTo ! PaymentRejected(paymentId, reason)
        running(state.copy(validating = state.validating - paymentId))

      case GetStatus(paymentId, replyTo) if state.validated.contains(paymentId) =>
        replyTo ! PaymentValidated(paymentId)
        Behaviors.same

      case GetStatus(paymentId, replyTo) if state.validating.contains(paymentId) =>
        replyTo ! PaymentPending(paymentId)
        Behaviors.same

      case GetStatus(paymentId, replyTo) =>
        replyTo ! PaymentUnknown(paymentId)
        Behaviors.same
    }
}

This example is still small, but the protocol quality is carrying a lot of weight.

  • SubmitPayment is a clear business command.
  • GetStatus is a separate query instead of being folded awkwardly into the submission path.
  • validation callbacks are private.
  • replies express business outcomes directly.
  • paymentId keeps the workflow correlated.

That is the kind of protocol that can survive the next few rounds of product requirements.

Common Protocol Smells in Akka Code

It helps to know what usually goes wrong.

Generic catch-all commands

Messages like Process, Handle, Execute, or Run usually mean the protocol is under-specified.

Overloaded request messages

If one message has many optional fields so it can represent several different operations, the actor boundary is probably too vague.

Replies with hidden meaning

If callers need to parse strings or interpret boolean flags to know what happened, the result model is probably too weak.

Public exposure of internal steps

If outside callers are expected to send timeout, retry, or callback messages directly, the actor's internal workflow is leaking.

Missing correlation identifiers

If asynchronous replies do not carry stable identifiers, debugging and state tracking become much harder under concurrency.

Infrastructure-shaped messages instead of domain-shaped messages

When protocols read like transport operations rather than business actions, the system becomes harder to explain and maintain.

None of these are theoretical problems. They show up directly in systems that become difficult to extend.

Good Protocols Make Testing Better Too

Another practical benefit of message design is that good protocols are easier to test.

If the commands and outcomes are explicit, tests can focus on observable behavior:

  • when a valid reservation arrives, the actor replies with Reserved
  • when quantity is invalid, the actor replies with InvalidReservation
  • when a duplicate payment arrives, the actor replies with PaymentRejected

That is much stronger than testing around vague side effects or inspecting internal state hacks.

In other words, good protocols are not just easier for humans to read. They produce better boundaries for automated verification.

Summary

Designing good message protocols is one of the highest-leverage habits in Akka.

If you remember only a few rules from this lesson, keep these:

  • name messages after business intent, not generic implementation verbs
  • require the data the actor genuinely needs
  • separate public commands from internal workflow messages
  • keep commands, replies, and events conceptually distinct
  • model outcomes explicitly instead of hiding meaning in booleans or strings
  • include correlation identifiers when work can be in flight asynchronously
  • design messages so they can survive normal system evolution

Akka works best when actor boundaries are clear. Good protocols are how you create that clarity.

In the next lesson, we will build on this by looking more closely at state, behavior, and time in actors. That is where protocol design meets the question of how actor state should evolve without becoming a pile of flags and ad hoc transitions.