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.

Advertisement

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.

The ticket-hold service: one guardian, one child actor per event, every state change is a messageHTTP routeask with 3 s timeoutHoldService guardianMap eventId to childHoldForEventInventory e-101available 500, held 0EventInventory e-202available 38, held 2forwardTimer: hold e-202-h1ExpireHold after 5 minself messagePayment clientFuture, piped to selfreply: Held(holdId) or Rejected(reason) goes straight to replyToEach actor handles one message at a time, so the inventory counts need no locks.Timers and Future results arrive as ordinary messages; nothing touches state from another thread.A crash restarts one event's actor under supervision; the other events keep serving.
Requests ask the guardian, which forwards to the event's actor. Replies go straight back to the caller. Timers and Future results come back as messages, so state is only touched by the actor itself.

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
Advertisement

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 ConfirmReply

Three 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.result or 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, or ctx.ask when 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

NeedActorsAlternative
Many small independent pieces of state with concurrent updatesStrong fitRow locks or optimistic versioning in a database
Timers, timeouts and per-entity workflowsStrong fitA scheduler plus a state table
Pure request-response transforms with no stateOverkillPlain functions and Futures, or an effect system
Data pipelines with backpressureUse Akka Streams on topfs2 or ZIO Streams
State that must survive crashesNeeds PersistenceA 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

  1. Create an sbt project with the typed actor and test kit modules, from Akka with a token or from Pekko.
  2. Type in the protocol and behaviour above and run the ManualTime test until it passes.
  3. Add a GetStatus(replyTo) message that reports available, held and sold, and test it.
  4. 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.
  5. Wire the ask call into one HTTP route and load-test it with two events to see actors spread the work.
  6. Read the Typed architecture and Persistence pages next, then cluster sharding when one machine is not enough.
Key takeaway: An Akka typed actor is a sealed message protocol plus a behaviour that handles one message at a time, so check-and-update logic needs no locks. Keep state in behaviour parameters, use timers for expiry, give each key its own child under a supervising guardian, use ask only at the edge and pipeToSelf for Futures inside actors, and test with probes and ManualTime. Remember that a restart resets state, so persist what you cannot lose.