Your First Useful Akka Typed Service

By lesson four, the goal should change. You already know why Akka exists, what actors are, and how the broader Akka ecosystem fits together. Now you need to see what a small but useful Akka Typed service actually looks like when it is solving a real problem.

That matters because too much Akka material still starts with ping-pong examples, counters, or actors that bounce strings back and forth. Those examples are fine for syntax, but they teach very little about the engineering decisions that matter in production. Real services have protocols, validation rules, state transitions, timeouts, handoffs, and boundaries between synchronous and asynchronous work.

In this lesson, we will build a small order intake service. It is not a full ecommerce platform, and it does not need clustering or persistence yet. But it is realistic enough to show the shape of good Akka Typed code:

  • explicit message protocols
  • actor-owned state
  • clear behavior transitions
  • small child actors with focused responsibilities
  • lifecycle decisions that are easy to reason about

The point is not that every system should look exactly like this. The point is to learn a practical baseline for writing actors that remain readable as the workflow grows.

The Scenario: Order Intake, Not Ping-Pong

Suppose you are building the first stage of an order platform. Requests arrive from an HTTP layer. Each request must be validated, accepted or rejected, and then forwarded for downstream processing.

Even in a simple version, there are several real concerns:

  • the service must not accept malformed orders
  • duplicate order IDs should be rejected cleanly
  • in-flight work should be visible in the actor state
  • validation should not turn the actor into a giant mutable object with many flags
  • the service should stay understandable when more business rules arrive later

This is a good first Akka Typed problem because it is stateful enough to be interesting without immediately dragging in the full complexity of persistence, clustering, or streams.

Start With the Protocol, Not the Implementation

One of the easiest ways to write confusing actor code is to begin with internal state and bolt messages on later. A better habit is to start with the protocol.

What messages should an order intake actor understand?

  • submit a new order
  • ask for a snapshot of current state
  • receive internal confirmation that validation finished
  • receive internal confirmation that downstream forwarding finished

That leads to a protocol like this:

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

object OrderIntake {
  sealed trait Command

  final case class SubmitOrder(
      orderId: String,
      customerId: String,
      items: List[OrderItem],
      total: BigDecimal,
      replyTo: ActorRef[SubmissionResult]
  ) extends Command

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

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

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

  private final case class ForwardingCompleted(orderId: String) extends Command

  final case class OrderItem(sku: String, quantity: Int, unitPrice: BigDecimal)

  final case class PendingOrder(
      orderId: String,
      customerId: String,
      items: List[OrderItem],
      total: BigDecimal
  )

  sealed trait SubmissionResult
  final case class Accepted(orderId: String) extends SubmissionResult
  final case class Rejected(orderId: String, reason: String) extends SubmissionResult

  final case class Snapshot(
      activeOrders: Int,
      knownOrders: Set[String]
  )
}

Even before we write behavior logic, this already tells us something important.

The actor exposes business messages such as SubmitOrder and GetSnapshot. It also has private internal messages such as ValidationPassed and ForwardingCompleted. That separation is useful. External callers see a stable public protocol. Internal workflow steps remain implementation details.

This is one of the first habits worth keeping in Akka Typed: make the public protocol easy to read from the top of the file.

Model Only the State You Actually Need

The next mistake teams make is to over-model the first actor. They anticipate every future workflow, add many maps and counters, and end up with a state object that becomes harder to reason about than the original threaded code.

For this service, we only need two facts:

  • which order IDs are already known
  • which orders are currently in flight

That can stay small:

private final case class State(
    knownOrderIds: Set[String],
    inFlight: Map[String, PendingOrder]
)

That is enough to reject duplicates, expose a snapshot, and track work until forwarding completes.

Notice what is not here:

  • no shared mutable collections
  • no status flags spread across the object
  • no direct external access to state

The actor owns this state and updates it by processing messages one at a time.

A First Behavior That Stays Small

Now we can write the main behavior. The goal is not cleverness. The goal is a structure that still makes sense after six months of changes.

import akka.actor.typed.scaladsl.{ActorContext, Behaviors}

object OrderIntake {
  // protocol types omitted here for brevity

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

  private def running(
      state: State,
      forwarder: ActorRef[OrderForwarder.Command]
  ): Behavior[Command] =
    Behaviors.receive { (context, message) =>
      message match {
        case SubmitOrder(orderId, customerId, items, total, replyTo) =>
          if (state.knownOrderIds.contains(orderId)) {
            replyTo ! Rejected(orderId, "Duplicate order id")
            Behaviors.same
          } else {
            val order = PendingOrder(orderId, customerId, items, total)
            startValidation(context, order, replyTo)
            running(
              state.copy(
                knownOrderIds = state.knownOrderIds + orderId,
                inFlight = state.inFlight + (orderId -> order)
              ),
              forwarder
            )
          }

        case ValidationPassed(order, replyTo) =>
          forwarder ! OrderForwarder.Forward(order, context.self)
          replyTo ! Accepted(order.orderId)
          Behaviors.same

        case ValidationFailed(orderId, reason, replyTo) =>
          replyTo ! Rejected(orderId, reason)
          running(
            state.copy(
              knownOrderIds = state.knownOrderIds - orderId,
              inFlight = state.inFlight - orderId
            ),
            forwarder
          )

        case ForwardingCompleted(orderId) =>
          running(
            state.copy(inFlight = state.inFlight - orderId),
            forwarder
          )

        case GetSnapshot(replyTo) =>
          replyTo ! Snapshot(
            activeOrders = state.inFlight.size,
            knownOrders = state.knownOrderIds
          )
          Behaviors.same
      }
    }

  private def startValidation(
      context: ActorContext[Command],
      order: PendingOrder,
      replyTo: ActorRef[SubmissionResult]
  ): Unit = {
    val validator = context.spawnAnonymous(OrderValidator())
    validator ! OrderValidator.Validate(order, context.self, replyTo)
  }
}

This already shows several useful patterns.

The Actor Owns Admission and Tracking

The intake actor decides whether an order ID is new, whether it should enter the workflow, and whether it should remain visible as in-flight work. That ownership is clear.

No other component reaches into a shared map and updates it directly. That is exactly the kind of boundary Akka is good at enforcing.

Behavior Transitions Can Be Ordinary Function Calls

Some developers expect behavior changes to look dramatic. In practice, most useful Akka Typed code changes behavior by calling the same function with updated state.

running(state.copy(inFlight = state.inFlight - orderId), forwarder)

That is enough. You do not need fancy state machines for every actor. The important point is that state evolution stays explicit.

Side Effects Are Present but Localized

The actor still has side effects. It spawns a validator. It sends a message to a forwarder. It replies to callers. But those effects are all visible at the message-handling boundary. They are not hidden in scattered callbacks mutating shared state elsewhere.

Use Child Actors to Keep Responsibilities Focused

If you keep stuffing validation, routing, retries, and external I/O into one actor, readability collapses fast. A better pattern is to let the parent actor own the workflow while child actors or collaborating actors own narrow tasks.

Here is a small validator actor:

object OrderValidator {
  sealed trait Command

  final case class Validate(
      order: OrderIntake.PendingOrder,
      replyTo: ActorRef[OrderIntake.Command],
      originalRequester: ActorRef[OrderIntake.SubmissionResult]
  ) extends Command

  def apply(): Behavior[Command] =
    Behaviors.receiveMessage {
      case Validate(order, replyTo, originalRequester) =>
        if (order.items.isEmpty) {
          replyTo ! OrderIntake.ValidationFailed(
            order.orderId,
            "Order must contain at least one item",
            originalRequester
          )
        } else if (order.total <= 0) {
          replyTo ! OrderIntake.ValidationFailed(
            order.orderId,
            "Order total must be positive",
            originalRequester
          )
        } else {
          replyTo ! OrderIntake.ValidationPassed(order, originalRequester)
        }

        Behaviors.stopped
    }
}

This actor is intentionally short-lived. It validates one order and stops.

That is a useful lifecycle decision to notice:

  • the intake actor is long-lived because it owns workflow state
  • the validator is short-lived because it performs one narrow task

Akka gets easier when actor lifetimes reflect the business responsibility instead of following one blanket rule.

Lifecycle Basics: Long-Lived Parents, Focused Workers

When people first use actors, they often ask whether every business concept should become a long-lived actor. Usually the answer is no.

For a service like this, there are at least three common lifecycles:

  • a long-lived parent that owns the overall workflow
  • short-lived workers for isolated checks or transformations
  • independent collaborators for downstream integration

The forwarding side might look like this:

object OrderForwarder {
  sealed trait Command
  final case class Forward(
      order: OrderIntake.PendingOrder,
      replyTo: ActorRef[OrderIntake.Command]
  ) extends Command

  def apply(): Behavior[Command] =
    Behaviors.receive { (context, message) =>
      message match {
        case Forward(order, replyTo) =>
          context.log.info("Forwarding order {} for downstream processing", order.orderId)
          replyTo ! OrderIntake.ForwardingCompleted(order.orderId)
          Behaviors.same
      }
    }
}

This version is deliberately simple, but it shows the architectural shape:

  • the intake actor owns admission and state transitions
  • the validator handles one validation task and exits
  • the forwarder acts as a boundary toward downstream processing

That separation becomes increasingly valuable once the system grows. It is much easier to replace the forwarder with real HTTP, Kafka, or persistence integration when it is already behind a clear message boundary.

A Small Main Program to Make the Structure Concrete

Even a minimal bootstrap helps developers see how the parts relate:

import akka.actor.typed.ActorSystem

object OrderApp extends App {
  val rootBehavior = Behaviors.setup[Nothing] { context =>
    val forwarder = context.spawn(OrderForwarder(), "order-forwarder")
    val intake = context.spawn(OrderIntake(forwarder), "order-intake")

    Behaviors.empty
  }

  val system = ActorSystem[Nothing](rootBehavior, "orders")
}

In a real application, an HTTP route or another service boundary would send SubmitOrder messages into order-intake. The important point here is that the actor topology stays understandable:

  • one root system
  • one long-lived intake actor
  • one forwarder collaborator
  • short-lived validator children created on demand

This is a much better starting point than one giant actor that tries to do everything.

Keep the Protocol Understandable as Complexity Grows

The syllabus for this course is practical for a reason. Akka systems rarely become difficult because the syntax is hard. They become difficult when protocols get vague.

Suppose the order service grows. Now you need:

  • fraud screening
  • inventory reservation
  • different acceptance rules for premium customers
  • dead-letter handling for downstream failures
  • retry policies with visibility into stuck work

If your initial actor already mixes business requests, internal workflow control, timeout signals, and infrastructure callbacks in one messy protocol, each new rule makes it harder to tell what the actor actually does.

A few habits help a lot:

  • keep public commands at the top of the protocol
  • make internal messages private when callers should not send them
  • use specific message names instead of generic ones like Process or Handle
  • keep state fields aligned with business meaning, not incidental implementation details
  • split responsibilities when one actor starts coordinating too many unrelated concerns

Those are not style preferences. They directly affect whether the system stays maintainable.

What This Example Deliberately Does Not Solve Yet

It is important to stay honest about scope.

This service is useful, but it is not production-complete. It does not yet cover:

  • durable recovery after a process crash
  • supervision strategies for failing downstream integrations
  • timers and scheduled retries
  • distributed deployment across nodes
  • stream-based ingestion when input volume becomes continuous

That is fine. A first useful Akka Typed service should not try to teach every Akka feature at once.

What it should teach is the shape of a healthy actor-based design:

  • model a real workflow
  • define an explicit protocol
  • keep state local and intentional
  • separate responsibilities with clear actor boundaries
  • use behavior transitions to make state changes visible

Once that foundation is solid, later topics such as supervision, ask pattern boundaries, streams, sharding, and persistence make much more sense.

Common Mistakes in First Akka Typed Services

Before moving on, it is worth calling out a few mistakes that show up constantly.

Treating Actors Like Async Objects

If every message is basically a disguised method call expecting an immediate answer, you are probably not using actors to shape the workflow. You are just rebuilding RPC with more ceremony.

Putting Too Much Logic in One Actor

One actor can own a workflow without owning every detail inside the workflow. If validation, external calls, retries, state transitions, and monitoring all pile into one receive block, it will become brittle quickly.

Hiding the Protocol Behind Generic Names

Names like DoWork, HandleEvent, or Update tell readers almost nothing. Clear message names are part of the architecture. They make operational behavior easier to explain and test.

Modeling Temporary Convenience Instead of Business Boundaries

If your state object contains fields because they make one branch easy today but do not correspond to meaningful workflow state, that is a warning sign. Actor state should help you explain what the service knows and why.

Summary

Your first useful Akka Typed service should not be a toy. It should be small enough to understand and realistic enough to teach the design habits that matter in production.

In this lesson, the important ideas were not the specific order domain rules. They were the structural choices:

  • start with a protocol
  • keep the actor responsible for a clear slice of state and workflow
  • use child actors or collaborators to avoid one giant receive block
  • make behavior transitions explicit
  • choose actor lifecycles that match responsibility

That is the baseline for writing Akka Typed code that still reads well after the initial demo phase ends.

In the next lesson, we will go deeper into one of the highest-leverage parts of actor design: message protocols that are explicit, safe, evolvable, and easy to reason about.