The Actor Model with Akka | Scala Programming Guide

- Published on

What Are Actors and Why They Exist
Traditional concurrency using threads and locks is fundamentally hard because it's based on shared mutable state: multiple threads access the same variables, and you must use locks to prevent races and data corruption. This approach has proven notoriously difficult in practice:
- Race conditions are subtle and data-dependent (they appear intermittently, usually in production under load)
- Deadlocks are easy to create accidentally when acquiring multiple locks (thread A waits for lock held by thread B, which waits for lock held by thread A)
- Shared mutable state requires meticulous synchronization at every access point; a single unprotected access can corrupt data or cause crashes
- Testing concurrent code becomes fragile and slow: you must test many scheduling permutations to flush out timing bugs, and tests are inherently nondeterministic
The Actor Model abandons shared mutable state entirely. Instead of threads fighting over shared data, actors are isolated entities that communicate exclusively through asynchronous messages. Each actor has a mailbox (queue of messages) and processes messages sequentially, one at a time. No locks, no races, no deadlocks—because there's no shared mutable state. Akka brings this model to the JVM with production-grade tooling.
The Actor Model is a different paradigm: instead of shared memory and locks, actors are isolated entities that communicate only via messages. Think of actors like a restaurant kitchen with specialized stations. Each station (actor) processes orders sequentially at their station. They don't grab food from someone else's cutting board (no shared state). Instead, they pass finished plates to the next station via a ticket window (message passing). If the grill station gets behind, it builds up a queue of tickets (mailbox), and other stations keep working. This isolation means no synchronization needed, no races—because no one is directly accessing someone else's workspace. Akka scales this model to thousands of actors running in a single JVM, each with its own mailbox, running independently and communicating via messages.
Akka is Scala's production actor framework, providing:
- Lightweight actors (millions can run in a single JVM)
- Location transparency (actors can be local or remote transparently)
- Supervision and self-healing (actors can recover from failures)
- Persistence (audit trails, event sourcing)
Actor Lifecycle
Actors progress through distinct states during their lifetime, each with associated callbacks you can override to add custom behavior. Understanding the lifecycle is crucial because it determines when state is initialized, cleaned up, and when side effects occur. The lifecycle is sequential: an actor is created, starts processing messages, then stops. Hooks along the way let you initialize resources (preStart), clean up on shutdown (postStop), or handle other transitions. These hooks run atomically with respect to message processing: you won't receive messages while preStart is running, guaranteeing consistent initialization.
┌─────────────────────────────────────────────────┐
│ Actor Lifecycle States │
├─────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ START │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ preStart() called │
│ │ CREATED │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ RECEIVING │◄────────────────┐ │
│ │ MESSAGES │ Process msg │ │
│ └──────┬──────┘ │ │
│ │ │ │
│ │ (message arrives) │ │
│ ├────────────────────────┘ │
│ │ │
│ stop() or PoisonPill │
│ │ │
│ ▼ │
│ ┌─────────────┐ postStop() called │
│ │ STOPPED │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────┘
An actor goes through these states:
- Created: The actor is instantiated,
preStart()is called. Use this to initialize state, establish connections, subscribe to topics, etc. - Receiving: The actor processes messages from its mailbox sequentially, one at a time. Each message triggers
receiveto pattern match and handle it. - Stopped: The actor stops (via
context.stop(self)orPoisonPill),postStop()is called. Use this to clean up resources, close connections, cancel timers, notify dependents.
Between receiving messages, the actor is idle but responsive. Its state is never modified from outside—only message handlers can change it. This is why actors are so much simpler than threads with locks: each actor's state is its own private concern.
Defining Actors and Handling Messages
Here's a concrete example: a chat room system where actors represent users and rooms. Each user is isolated; they don't directly access other users' state. Instead, they send messages to rooms, rooms broadcast to users. This decoupling means a room can be replaced, a user can be added or removed, and nothing breaks because they only know each other through messages.
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
import scala.collection.mutable
// Message types (actors communicate using messages)
sealed trait ChatMessage
case class JoinRoom(roomName: String) extends ChatMessage
case class SendMessage(text: String, roomName: String) extends ChatMessage
case class UserLeft(username: String) extends ChatMessage
case class DisplayMessage(username: String, text: String) extends ChatMessage
// A user actor
class UserActor(username: String) extends Actor {
private var currentRoom: Option[ActorRef] = None
override def preStart(): Unit = {
println(s"[UserActor] $username is starting")
}
override def receive: Receive = {
case JoinRoom(roomName) =>
println(s"[$username] Joining room: $roomName")
currentRoom = Some(sender()) // Remember which room actor sent this
sender() ! DisplayMessage(username, s"$username has entered the room")
case DisplayMessage(from, text) =>
// Receive a message from the room
println(s"[$username] <$from>: $text")
case SendMessage(text, _) =>
currentRoom.foreach { room =>
// Send our message to the room
room ! DisplayMessage(username, text)
}
case UserLeft(user) =>
println(s"[$username] $user has left the room")
}
override def postStop(): Unit = {
println(s"[UserActor] $username is stopping")
currentRoom.foreach { room =>
room ! UserLeft(username)
}
}
}
// A chat room actor
class ChatRoomActor(name: String) extends Actor {
private var users: mutable.Set[ActorRef] = mutable.Set()
override def preStart(): Unit = {
println(s"[ChatRoom] $name is created")
}
override def receive: Receive = {
case JoinRoom(roomName) if roomName == name =>
// A user wants to join
users += sender()
println(s"[ChatRoom] $name now has ${users.size} users")
// Acknowledge the join
sender() ! JoinRoom(name)
// Broadcast to all users that someone joined
broadcast(DisplayMessage(name, s"User joined. Total: ${users.size}"))
case DisplayMessage(from, text) =>
// A user sent a message, broadcast to all
broadcast(DisplayMessage(from, text))
case UserLeft(username) =>
users -= sender()
println(s"[ChatRoom] User left. Remaining: ${users.size}")
broadcast(DisplayMessage(name, s"$username left. Total: ${users.size}"))
}
private def broadcast(message: DisplayMessage): Unit = {
// Send to all users in the room
users.foreach(_ ! message)
}
override def postStop(): Unit = {
println(s"[ChatRoom] $name is stopping")
}
}
Key concepts:
- sealed trait ChatMessage: Type-safe message definitions
- receive: Pattern matches on incoming messages
- sender(): Reference to the actor that sent the current message
- !: Send a message (fire-and-forget)
tell (!) vs ask (?)
Akka provides two communication patterns, each with different tradeoffs. Tell (!) is asynchronous, non-blocking: you send a message and continue immediately without waiting for a response. The sender never blocks. This preserves the actor model philosophy—everything is async, nothing waits. Ask (?) is request-reply: you send a message and get back a Future for the response. This breaks the actor model principle because you're now waiting (or composing a Future to wait), but sometimes you need request-reply semantics. The tradeoff is complex: tell scales to millions of messages per second on a single actor; ask scales much lower because each invocation creates a temporary actor to handle the response. Choose carefully.
Think of tell like sending an email: you send a message and continue your day. You don't wait for a response. Ask is like making a phone call: you dial, wait on the line for someone to pick up, then have a conversation. Emails are lightweight and asynchronous; phone calls require active waiting. In actor systems, messages are emails (tell), responses are phone calls (ask). You want mostly emails. Occasionally you need a phone call, but if you're making phone calls all the time, the system becomes synchronous and loses the benefits of the actor model.
Here's a more detailed distinction: with tell, sender and receiver are decoupled in time. The sender doesn't know or care when the receiver processes the message. This allows flexible scheduling: a receiver can be replaced, restarted, or moved to another machine, and the sender doesn't care. With ask, the sender is blocked waiting for a response, creating a temporal coupling: the sender and receiver must coordinate timing. If the receiver is slow or crashes, the sender is affected. This coupling makes the system harder to reason about and more fragile.
// tell (!): fire-and-forget
// The sender doesn't wait for a response
def sendMessageFireAndForget(roomRef: ActorRef, message: ChatMessage): Unit = {
roomRef ! message // Returns immediately
println("Message sent, not waiting for response")
}
// ask (?): request-reply
// The sender waits for a response (returns a Future)
import akka.pattern.ask
import scala.concurrent.duration._
def sendMessageAndWaitForReply(roomRef: ActorRef, message: ChatMessage): Unit = {
implicit val timeout: akka.util.Timeout = 5.seconds
// ask returns Future[Any]
val response: concurrent.Future[Any] = roomRef ? message
response.onComplete {
case scala.util.Success(reply) =>
println(s"Received reply: $reply")
case scala.util.Failure(ex) =>
println(s"No reply within timeout: ${ex.getMessage}")
}
}
// Example with explicit ask and pattern matching
def queryRoomStatus(roomRef: ActorRef): concurrent.Future[String] = {
implicit val timeout: akka.util.Timeout = 3.seconds
// The room actor must respond with a String
(roomRef ? "status").mapTo[String]
}
When to use:
!(tell): Most of the time (fire-and-forget is non-blocking). Default to tell and only use ask when you truly need a response.?(ask): When you absolutely need a response (but it breaks actor isolation)
Tell is preferred because:
- Non-blocking: sender doesn't wait. Multiple senders can send thousands of tells per second to the same actor.
- Matches the actor model philosophy (async communication). Preserves decoupling between sender and receiver.
- Better throughput. Tell operations scale linearly; ask operations have per-message overhead.
Ask should be used sparingly because:
- Blocks the sender's thread (or returns a Future that blocks). Creates temporal coupling between sender and receiver.
- Complex error handling (timeout, actor death). If no reply arrives within the timeout, ask fails. If the actor dies, ask fails. These must be handled carefully.
- Can cause deadlocks if not careful. If actor A asks actor B, and B asks A back on the same ExecutionContext, they deadlock.
Common pitfall: Overusing ask in high-throughput systems. If every message requires a response, you've essentially gone back to synchronous request-reply. The throughput collapses, latency explodes, and you've lost the benefits of the actor model.
Actor Hierarchies and Supervision Strategies
Actors form a tree hierarchy. When an actor is created, it has a parent. Parents supervise their children: they decide what happens if a child fails (crashes with an exception). This is the actor model's built-in fault tolerance. When a child throws an exception, the actor runtime catches it, tells the parent (supervisor), and asks "what should we do?" The parent's supervisory strategy decides: restart the child (wipe its state, run preStart again), resume (ignore the error and keep going), stop the child (shut it down), or escalate to the parent's parent. This creates a hierarchy of responsibility. Leaf actors might handle their own errors; middle actors might restart children; top-level actors might escalate to the application. The strategy is pattern-based: different exception types trigger different actions.
Think of supervision like an organization: a manager (parent) supervises employees (children). If an employee makes a mistake, the manager doesn't terminate employment immediately; they might give a warning (resume), have them redo their work (restart), or in serious cases, fire them (stop). Different mistakes deserve different responses. Actors formalize this: the parent's supervisory strategy is explicit code that responds to failure types.
The deep reason supervision matters: traditional error handling with try-catch is local. Each function must handle errors it might throw. With actors, errors bubble up to the parent, who decides the response. This means a single supervisory strategy can handle errors from many children. For example, a payment actor might handle payment errors uniformly: retry up to 3 times, then stop. All children share this policy automatically. Without supervision, you'd duplicate error handling logic in every child. With supervision, you centralize it.
import akka.actor.SupervisorStrategy
import akka.actor.OneForOneStrategy
import scala.concurrent.duration._
// A worker that processes messages and might fail
class OrderWorkerActor extends Actor {
override def receive: Receive = {
case order: String =>
println(s"[OrderWorker] Processing: $order")
if (order.contains("invalid")) {
throw new IllegalArgumentException("Invalid order format")
}
// Process the order
println(s"[OrderWorker] Completed: $order")
}
}
// A supervisor that manages workers
class OrderProcessorActor extends Actor {
// Define supervision strategy
override val supervisorStrategy: SupervisorStrategy =
OneForOneStrategy(maxNrOfRetries = 3, withinTimeRange = 1.minute) {
case _: IllegalArgumentException =>
// Restart the child actor on validation errors
SupervisorStrategy.Restart
case _: RuntimeException =>
// Resume (ignore the error, continue processing)
SupervisorStrategy.Resume
case _: Exception =>
// Stop the child completely
SupervisorStrategy.Stop
}
// Create worker children
private val workers = (1 to 5).map { i =>
context.actorOf(Props[OrderWorkerActor], name = s"worker-$i")
}
private var nextWorkerIndex = 0
override def receive: Receive = {
case order: String =>
// Route the order to the next worker (round-robin)
workers(nextWorkerIndex) ! order
nextWorkerIndex = (nextWorkerIndex + 1) % workers.length
}
override def postStop(): Unit = {
println("[OrderProcessor] Stopping all workers")
workers.foreach(context.stop)
}
}
Supervision Strategies:
Each strategy represents a response to child failure:
Restart: Kill and restart the child (clears its state, retries). The child is stopped, preStart is called again, it starts fresh. Use this for transient failures (network timeout, temporary overload). The actor should be idempotent: restarting should be safe.
Resume: Ignore the error, continue with the same actor. The actor keeps its state as-is; the error is silently swallowed. Use this sparingly, only when you're certain the error doesn't corrupt state. Most errors should trigger Restart or Stop, not Resume.
Stop: Permanently stop the child. The actor's postStop is called; it's shut down and can't process more messages. Use this for permanent failures (bad configuration, invalid data) where recovery is impossible.
Escalate: Pass the error up to the parent. The parent's supervisor handles it. Use this when the child doesn't know how to respond and needs the parent's judgment.
Supervision parameters:
maxNrOfRetries = 3: If a child fails more than 3 times, escalate to the parent instead of restarting again.withinTimeRange = 1.minute: The retry limit applies within a time window. If a child fails 3 times in 1 minute, escalate. If it fails once per hour, that's OK.
Routers and Routing Strategies
When you have many identical workers, routers distribute load among them automatically. Rather than manually selecting a worker for each message, you send messages to a router, which picks a worker and forwards the message. The router handles load balancing, so workers stay evenly busy. Different routing strategies optimize for different goals: round-robin is fair and predictable; random spreads load; balancing pool lets workers pull work from a shared queue and naturally self-balance.
Routers are a powerful pattern for scaling: they let you create a pool of workers and automatically distribute work. If a worker crashes, the router can restart it automatically (depends on the router and supervision strategy). This decouples the sender from workers: senders don't know or care how many workers there are; they just send to the router.
import akka.routing.{RoundRobinPool, RandomPool, BalancingPool}
object OrderProcessingSystem {
def main(args: Array[String]): Unit = {
val system = ActorSystem("order-system")
// Round-robin router: sends messages alternately to each worker
val roundRobinRouter = system.actorOf(
RoundRobinPool(10).props(Props[OrderWorkerActor]),
"round-robin"
)
// Random router: sends messages to random workers
val randomRouter = system.actorOf(
RandomPool(10).props(Props[OrderWorkerActor]),
"random"
)
// Balancing pool: workers pull work from a shared queue
val balancingRouter = system.actorOf(
BalancingPool(10).props(Props[OrderWorkerActor]),
"balancing"
)
// Send orders to the router, it distributes them
(1 to 100).foreach { i =>
roundRobinRouter ! s"order-$i"
}
}
}
Scheduling and Timers
Actors can schedule tasks: send themselves messages at specific times. This is useful for periodic checks, heartbeats, timeouts, and other time-based behavior. The scheduler runs tasks on ExecutionContext threads, not the actor's mailbox thread. This means a scheduled task doesn't directly access the actor's state; it must send a message, which the actor receives and processes sequentially.
import akka.actor.Actor
class HeartbeatActor extends Actor {
import context.dispatcher
import scala.concurrent.duration._
override def preStart(): Unit = {
// Schedule a message to self after 1 second, then every 2 seconds
context.system.scheduler.scheduleAtFixedRate(
initialDelay = 1.second,
interval = 2.seconds,
receiver = self,
message = "heartbeat"
)
}
override def receive: Receive = {
case "heartbeat" =>
println(s"Heartbeat at ${System.currentTimeMillis()}")
case "stop-heartbeat" =>
// Cancel all scheduled tasks
context.system.scheduler.shutdown()
}
}
// Using timers (modern approach)
import akka.actor.typed.scaladsl.Behaviors
import scala.concurrent.duration._
class TimerBasedActor {
// This is the typed actor API (newer, more robust)
def behavior = Behaviors.setup[String] { ctx =>
ctx.setTimer("periodic-timer", "tick", 2.seconds, isRepeating = true)
Behaviors.receiveMessage {
case "tick" =>
println("Timer fired!")
Behaviors.same
case "cancel" =>
ctx.cancelTimer("periodic-timer")
Behaviors.same
}
}
}
Actor Persistence (Event Sourcing Basics)
Actors can persist their state using event sourcing: instead of saving state snapshots, they store events (immutable records of what happened). When an actor restarts, it replays events to rebuild state. This provides an audit trail, enables time travel (replay from any point), and is naturally resilient (crashes are harmless; just replay events again). Event sourcing is a different mental model: instead of thinking "save state," think "record what happened."
import akka.persistence.{PersistentActor, SnapshotOffer}
// Events represent things that happened
sealed trait InventoryEvent
case class ItemAdded(item: String, quantity: Int) extends InventoryEvent
case class ItemRemoved(item: String, quantity: Int) extends InventoryEvent
// State is reconstructed by replaying events
case class InventoryState(items: Map[String, Int] = Map()) {
def addItem(item: String, qty: Int): InventoryState =
copy(items = items + (item -> (items.getOrElse(item, 0) + qty)))
def removeItem(item: String, qty: Int): InventoryState =
copy(items = items + (item -> (items.getOrElse(item, 0) - qty)))
}
// A persistent actor that stores events
class InventoryActor extends PersistentActor {
override def persistenceId: String = "inventory-1"
private var state = InventoryState()
// Handle commands (requests)
override def receiveCommand: Receive = {
case ItemAdded(item, qty) =>
// Persist the event, then update state
persist(ItemAdded(item, qty)) { event =>
state = state.addItem(event.item, event.quantity)
println(s"Added: ${event.item} x ${event.quantity}, State: ${state.items}")
}
case ItemRemoved(item, qty) =>
persist(ItemRemoved(item, qty)) { event =>
state = state.removeItem(event.item, event.quantity)
println(s"Removed: ${event.item} x ${event.quantity}, State: ${state.items}")
}
case "query-state" =>
sender() ! state
}
// Handle recovery (replay events on restart)
override def receiveRecover: Receive = {
case event: ItemAdded =>
state = state.addItem(event.item, event.quantity)
case event: ItemRemoved =>
state = state.removeItem(event.item, event.quantity)
case SnapshotOffer(_, snapshot: InventoryState) =>
// Use snapshot instead of replaying all events
state = snapshot
}
}
// Usage
val system = ActorSystem("inventory-system")
val inventory = system.actorOf(Props[InventoryActor], "inventory")
inventory ! ItemAdded("apple", 100)
inventory ! ItemAdded("banana", 50)
inventory ! ItemRemoved("apple", 10)
// If the actor restarts, it replays these events to rebuild its state
Event sourcing benefits:
- Complete audit trail (you know everything that happened)
- Can replay from any point in time
- Resilient to crashes (state reconstructs from events)
- Testable (just replay events and check state)
Practical Example: Order Processing Pipeline
A complete order processing system using actors. Each stage (payment, shipping) is a separate actor. The orchestrator coordinates them. This architecture is loosely coupled: each actor knows about messages but not about other actors' implementations. If you want to add a new stage (tax calculation), you add an actor and wire it into the orchestrator. Existing actors don't change. This is the power of message passing: extensibility without modification.
import akka.actor.{Actor, ActorRef, ActorSystem, Props}
// Message types
case class Order(id: String, items: List[String], totalPrice: Double)
case class OrderConfirmed(orderId: String)
case class PaymentProcessed(orderId: String, amount: Double)
case class ShippingScheduled(orderId: String)
// Payment validation actor
class PaymentActorForOrders extends Actor {
override def receive: Receive = {
case Order(id, items, price) =>
println(s"[Payment] Processing payment for order $id: $$$price")
Thread.sleep(500) // Simulate payment processing
sender() ! PaymentProcessed(id, price)
println(s"[Payment] Completed for order $id")
}
}
// Shipping actor
class ShippingActorForOrders extends Actor {
override def receive: Receive = {
case PaymentProcessed(orderId, _) =>
println(s"[Shipping] Scheduling delivery for order $orderId")
Thread.sleep(300) // Simulate scheduling
sender() ! ShippingScheduled(orderId)
println(s"[Shipping] Scheduled for order $orderId")
}
}
// Order orchestrator (coordinates the pipeline)
class OrderOrchestratorActor(
paymentActor: ActorRef,
shippingActor: ActorRef
) extends Actor {
override def receive: Receive = {
case order: Order =>
println(s"[Orchestrator] Received order ${order.id}")
// Step 1: Process payment
paymentActor ! order
case PaymentProcessed(orderId, amount) =>
println(s"[Orchestrator] Payment confirmed for $orderId")
// Step 2: Schedule shipping
shippingActor ! PaymentProcessed(orderId, amount)
case ShippingScheduled(orderId) =>
println(s"[Orchestrator] Order $orderId is complete")
sender() ! OrderConfirmed(orderId)
}
}
// Main system
object OrderProcessingPipeline {
def main(args: Array[String]): Unit = {
val system = ActorSystem("order-pipeline")
val paymentActor = system.actorOf(Props[PaymentActorForOrders], "payment")
val shippingActor = system.actorOf(Props[ShippingActorForOrders], "shipping")
val orchestrator = system.actorOf(
Props(new OrderOrchestratorActor(paymentActor, shippingActor)),
"orchestrator"
)
// Send multiple orders
orchestrator ! Order("O1", List("Widget", "Gadget"), 99.99)
orchestrator ! Order("O2", List("Thing"), 49.99)
orchestrator ! Order("O3", List("Item1", "Item2", "Item3"), 199.99)
// Let it run
Thread.sleep(5000)
system.terminate()
}
}
This architecture:
- Decouples payment, shipping, and orchestration
- Each actor can be scaled independently
- Easy to add retry logic, timeouts, or additional steps
- Testable by injecting mock actors