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
ProcessorHandle - 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.
Comments
Be the first to comment on this lesson!