Most introductions to actors explain the model and stop. This one builds something. By the end you will have a small ticket-hold service in Scala: customers hold seats for an event, holds expire after five minutes unless confirmed, and many requests arrive at once without a single lock in your code. Each step adds one Akka typed feature and explains why it exists.
If you want the wider tour first (mailboxes, dispatchers, streams, cluster and the licence history) read Akka, the actor model for Scala and Java. For the internals of the typed API read the Akka Typed architecture. Here the goal is practical: write, run and test your first actor system, and know which mistakes to avoid.
The problem and why an actor fits it
An event has a fixed number of seats. Many web requests try to hold seats at the same moment. A hold takes seats out of the pool, a confirmation turns them into a sale, and an unconfirmed hold must give its seats back when it expires. With shared mutable state you would need a lock or a database transaction around every check-and-decrement, plus a background sweeper for expiries that also takes the lock.
An actor removes the shared part. One actor owns one event's counts, and it handles one message at a time from its mailbox. A check-and-decrement inside one message handler cannot interleave with another, because there is no other: the next message waits. Expiry becomes a message the actor sends to itself. Concurrency comes from having many actors, one per event, which the runtime schedules across a thread pool.
Setting up the project
You need the typed actor module and its test kit. Current Akka releases come from Akka's own repository through a tokenized URL, and production use needs a licence key, so register first and read the current terms. Apache Pekko is the Apache-licensed fork of Akka 2.6. It has the same API under org.apache.pekko and is published to Maven Central. Everything on this page works on either. Only the imports change.
// build.sbt. Akka: register for a repository token, then add the tokenized resolver.
// Production use of current Akka releases also needs a licence key (free tiers exist; check terms).
resolvers += "Akka library repository".at("https://repo.akka.io/YOUR_TOKEN/secure")
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-actor-typed" % AkkaVersion,
"com.typesafe.akka" %% "akka-actor-testkit-typed" % AkkaVersion % Test,
"org.scalatest" %% "scalatest" % ScalaTestVersion % Test
)
// Apache Pekko instead: same API, packages under org.apache.pekko, Maven Central, Apache licence.
// "org.apache.pekko" %% "pekko-actor-typed" % PekkoVersion
Step 1: the protocol is the API
Start with the messages, not the class. In Akka typed an actor is an ActorRef[Command], and the compiler only lets callers send it a Command. A sealed trait lists every message, so pattern matches are checked for completeness. Each request that expects an answer carries an ActorRef of the reply type, which is how the actor knows where to answer.
import akka.actor.typed.{ActorRef, Behavior, SupervisorStrategy}
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler}
import scala.concurrent.duration._
object EventInventory {
// The protocol is the public API. Everything is immutable; replies carry their own type.
sealed trait Command
final case class Hold(seats: Int, replyTo: ActorRef[HoldReply]) extends Command
final case class Confirm(holdId: String, replyTo: ActorRef[ConfirmReply]) extends Command
final case class Release(holdId: String) extends Command
private final case class ExpireHold(holdId: String) extends Command // only the actor sends this
sealed trait HoldReply
final case class Held(holdId: String, expiresIn: FiniteDuration) extends HoldReply
final case class Rejected(reason: String) extends HoldReply
sealed trait ConfirmReply
final case class Confirmed(holdId: String) extends ConfirmReply
final case class UnknownHold(holdId: String) extends ConfirmReplyThree choices here matter later. Messages are case classes of immutable values, because a message is shared between threads the moment you send it. ExpireHold is private, so code outside the object cannot fake an expiry. And replies are their own small hierarchies, so a caller waiting for a HoldReply cannot receive a ConfirmReply by mistake.
Step 2: state lives in the behaviour
A typed actor is defined by a Behavior[Command]: what to do with the next message. The functional style keeps state in method parameters and returns a new behaviour with the updated values. Behaviors.same means nothing changed.
val HoldTtl: FiniteDuration = 5.minutes
def apply(eventId: String, capacity: Int): Behavior[Command] =
Behaviors.withTimers { timers =>
running(eventId, available = capacity, holds = Map.empty, sold = 0, timers, nextId = 1)
}
// State lives in the parameters. Each message returns the behaviour for the next message.
private def running(eventId: String, available: Int, holds: Map[String, Int], sold: Int,
timers: TimerScheduler[Command], nextId: Long): Behavior[Command] =
Behaviors.receiveMessage {
case Hold(seats, replyTo) if seats <= 0 || seats > 10 =>
replyTo ! Rejected("seats must be 1 to 10")
Behaviors.same
case Hold(seats, replyTo) if seats > available =>
replyTo ! Rejected(s"only $available left")
Behaviors.same
case Hold(seats, replyTo) =>
val holdId = s"$eventId-h$nextId"
timers.startSingleTimer(holdId, ExpireHold(holdId), HoldTtl) // key = holdId
replyTo ! Held(holdId, HoldTtl)
running(eventId, available - seats, holds + (holdId -> seats), sold, timers, nextId + 1)
case Confirm(holdId, replyTo) =>
holds.get(holdId) match {
case Some(seats) =>
timers.cancel(holdId)
replyTo ! Confirmed(holdId)
running(eventId, available, holds - holdId, sold + seats, timers, nextId)
case None =>
replyTo ! UnknownHold(holdId) // expired or never existed
Behaviors.same
}
case Release(holdId) => giveBack(eventId, available, holds, sold, timers, nextId, holdId)
case ExpireHold(holdId) => giveBack(eventId, available, holds, sold, timers, nextId, holdId)
}
private def giveBack(eventId: String, available: Int, holds: Map[String, Int], sold: Int,
timers: TimerScheduler[Command], nextId: Long, holdId: String): Behavior[Command] =
holds.get(holdId) match {
case Some(seats) =>
timers.cancel(holdId)
running(eventId, available + seats, holds - holdId, sold, timers, nextId)
case None => Behaviors.same // idempotent: already gone
}
}Read the Hold branch as one indivisible step: check capacity, start the timer, reply and return the new state. No other message can run between those lines. The guards come first, so invalid requests are rejected without touching state. Release and ExpireHold share one idempotent helper, which matters because a release and an expiry can race: whichever message arrives second finds the hold gone and changes nothing.
A class extending AbstractBehavior with var fields also works, provided the vars are only touched inside the handler.
Step 3: timers instead of sweepers
Behaviors.withTimers gives the actor a TimerScheduler. startSingleTimer(key, msg, delay) delivers msg to the actor itself after the delay, and starting a timer with an existing key replaces the old one. cancel(key) stops it. Timers are tied to the actor's life: when the actor stops or restarts, its timers are cancelled, so no expiry can arrive for an incarnation that no longer exists.
Using the hold id as the timer key gives one timer per hold and makes cancellation on confirm trivial. Never call Thread.sleep in an actor to wait. It blocks a thread from the shared pool, and every other actor on that pool slows down.
Step 4: a guardian and children
Every ActorSystem has one top-level guardian behaviour. Ours spawns one EventInventory child per event and forwards requests by event id. Each child is wrapped in a supervisor, so if it throws, Akka restarts it.
object HoldService {
sealed trait Command
final case class HoldFor(eventId: String, seats: Int, replyTo: ActorRef[EventInventory.HoldReply]) extends Command
def apply(capacities: Map[String, Int]): Behavior[Command] =
Behaviors.setup { ctx =>
val children: Map[String, ActorRef[EventInventory.Command]] =
capacities.map { case (id, cap) =>
val child = Behaviors.supervise(EventInventory(id, cap))
.onFailure[IllegalStateException](SupervisorStrategy.restart)
id -> ctx.spawn(child, s"event-$id")
}
Behaviors.receiveMessage { case HoldFor(eventId, seats, replyTo) =>
children.get(eventId) match {
case Some(ref) => ref ! EventInventory.Hold(seats, replyTo) // reply goes direct to caller
case None => replyTo ! EventInventory.Rejected(s"no event $eventId")
}
Behaviors.same
}
}
}Restart semantics deserve a careful look. Supervision restarts the child with its initial behaviour, so an in-memory actor loses its holds and sold count on restart. Here that is a real bug: a restarted event would oversell. Supervision protects the process, not the data. Real state needs to be rebuilt from storage on start, or kept with event sourcing as described in Akka Persistence. Also notice the guardian passes the caller's replyTo to the child, so the answer skips the guardian and it never becomes a bottleneck for replies.
Step 5: calling actors from ordinary code
HTTP handlers and tests are not actors, so they use the ask pattern. ask creates a short-lived reply address, sends the message, and returns a Future that completes with the first reply or fails with a timeout.
import akka.actor.typed.{ActorSystem, Scheduler}
import akka.actor.typed.scaladsl.AskPattern._
import akka.util.Timeout
import scala.concurrent.{ExecutionContext, Future}
val system: ActorSystem[HoldService.Command] =
ActorSystem(HoldService(Map("e-101" -> 500, "e-202" -> 40)), "tickets")
implicit val timeout: Timeout = 3.seconds
implicit val scheduler: Scheduler = system.scheduler
implicit val ec: ExecutionContext = system.executionContext
// The ask pattern creates a temporary ActorRef for the reply and completes the Future with it.
def holdSeats(eventId: String, seats: Int): Future[EventInventory.HoldReply] =
system.ask(replyTo => HoldService.HoldFor(eventId, seats, replyTo))
holdSeats("e-101", 2).map {
case EventInventory.Held(id, ttl) => s"201 hold $id valid for $ttl"
case EventInventory.Rejected(why) => s"409 $why"
}.recover { case _: java.util.concurrent.TimeoutException => "503 try again" }The timeout is part of your API contract. A timeout does not cancel anything: the hold may still succeed after the caller has given up, and it will then expire on its own five minutes later. That is acceptable here. For operations where it is not, make the request idempotent with a client-supplied id so a retry finds the earlier result.
Inside an actor, the rule is stricter. When an actor calls something that returns a Future, such as a payment client, it must not update state in the callback, because that callback runs on another thread while the actor may be handling a different message. Convert the result into a message instead:
// Inside an actor: never touch state from a Future callback. Turn the result into a message.
private final case class PaymentResult(holdId: String, ok: Boolean) extends Command
ctx.pipeToSelf(paymentClient.charge(holdId, amount)) {
case scala.util.Success(receipt) => PaymentResult(holdId, receipt.approved)
case scala.util.Failure(_) => PaymentResult(holdId, ok = false)
}
Step 6: testing without waiting
The typed test kit spawns real actors and gives you probes, which are ActorRefs you can assert on. ManualTime replaces the scheduler with one you advance yourself, so a five-minute expiry test runs in milliseconds and never flakes.
import akka.actor.testkit.typed.scaladsl.{ManualTime, ScalaTestWithActorTestKit}
import org.scalatest.wordspec.AnyWordSpecLike
class EventInventorySpec extends ScalaTestWithActorTestKit(ManualTime.config) with AnyWordSpecLike {
val manualTime: ManualTime = ManualTime()
import EventInventory._
"EventInventory" should {
"return seats when a hold expires" in {
val inv = spawn(EventInventory("e-1", capacity = 2))
val holds = createTestProbe[HoldReply]()
inv ! Hold(2, holds.ref)
val held = holds.expectMessageType[Held]
inv ! Hold(1, holds.ref)
holds.expectMessage(Rejected("only 0 left"))
manualTime.timePasses(HoldTtl + 1.second) // the timer fires, no real waiting
val confirms = createTestProbe[ConfirmReply]()
inv ! Confirm(held.holdId, confirms.ref)
confirms.expectMessage(UnknownHold(held.holdId)) // expired holds cannot be confirmed
inv ! Hold(2, holds.ref)
holds.expectMessageType[Held] // capacity came back
}
}
}Test the protocol, not the internals: send messages, assert on replies. That keeps tests valid when you later switch the actor from in-memory state to persistence. For pure logic without timing, BehaviorTestKit runs a behaviour synchronously and lets you inspect spawned children and effects. General Scala test setup is covered in testing in Scala.
Mistakes that bite in the first month
- Blocking inside a handler. JDBC calls,
Await.resultor sleeps stall a dispatcher thread. Run blocking work on a dedicated dispatcher and pipe the result back. - Closing over state in Future callbacks. The callback runs concurrently with the actor. Use
pipeToSelf. - Mutable messages. Sending a mutable collection shares it across threads. Send immutable values.
- Trusting restart to keep data. Supervision resets state. Persist anything you cannot lose.
- Ask everywhere. Ask between actors adds a timeout and a temporary actor per call. Between actors, prefer tell with a
replyTo, orctx.askwhen you need correlation and a timeout. - One giant actor. A single actor for all events makes every request wait in one mailbox. Split state by key so work spreads out, which is also what cluster sharding does across machines.
- Unbounded mailboxes under overload. If producers outrun an actor, its mailbox grows until memory runs out. Reject early, as the hold limit does, or add backpressure with streams.
Trade-offs: when actors are the right tool
| Need | Actors | Alternative |
|---|---|---|
| Many small independent pieces of state with concurrent updates | Strong fit | Row locks or optimistic versioning in a database |
| Timers, timeouts and per-entity workflows | Strong fit | A scheduler plus a state table |
| Pure request-response transforms with no state | Overkill | Plain functions and Futures, or an effect system |
| Data pipelines with backpressure | Use Akka Streams on top | fs2 or ZIO Streams |
| State that must survive crashes | Needs Persistence | A database as the source of truth |
If your service has no long-lived state per key, an effect system may serve you better; Cats Effect is the usual comparison in Scala.
What to do next
- Create an sbt project with the typed actor and test kit modules, from Akka with a token or from Pekko.
- Type in the protocol and behaviour above and run the ManualTime test until it passes.
- Add a
GetStatus(replyTo)message that reports available, held and sold, and test it. - Kill a child on purpose (throw on a magic seat count) and watch it restart and lose state, then decide how you will persist it.
- Wire the ask call into one HTTP route and load-test it with two events to see actors spread the work.
- Read the Typed architecture and Persistence pages next, then cluster sharding when one machine is not enough.