State, Behavior, and Time in Actors
Once you stop treating actors as just "objects that receive messages," Akka starts to make more sense. The real shift is this: an actor is a state machine that evolves over time by processing messages. Its current behavior depends on the state it owns, the messages it has already seen, and the time-based events that may arrive next.
That sounds abstract, but it is a very practical idea. Many backend systems are full of workflows that change over time:
- a user session becomes active, idle, and then expired
- a rate limiter allows some requests, delays others, and resets capacity later
- a shopping cart accepts items until checkout, then stops accepting changes
- a support conversation is opened, assigned, escalated, and closed
Those workflows are awkward when state is scattered across mutable fields, shared maps, scheduled callbacks, and database flags that several threads can touch at once. They become easier to reason about when one actor owns the state and the transitions are explicit.
This lesson is about that shift. We will look at how actors model state, how behaviors evolve, how time becomes part of the protocol, and how to keep those workflows readable instead of turning them into message-driven spaghetti.
Actors Are Stateful, but the State Is Private
One of the easiest mistakes in Akka is to think the actor model is mainly about asynchronous messaging. Messaging matters, but the deeper benefit is state ownership.
An actor is useful because it owns a small piece of mutable reality:
- the current shopping cart contents
- the current per-user rate-limit budget
- the current lifecycle stage of a payment attempt
- the current session state for one connected client
That state does not leak out as public mutable fields. Other parts of the system do not reach in and change it directly. They can only send messages that the actor may or may not accept in its current state.
That last point is critical.
In a normal object-oriented design, the public API often stays fixed while the internal object tries to defend itself with conditionals, locks, and guard clauses. In an actor-based design, it is often better to think in terms of behavior: what messages are valid now, and what should happen next?
Behavior Is a Function of Current State
Akka Typed makes this idea explicit. A behavior is not just a bag of handlers. It is the current definition of how the actor responds to incoming messages.
In practice, most useful actors look like this:
import akka.actor.typed.Behavior
import akka.actor.typed.scaladsl.Behaviors
object SessionActor {
sealed trait Command
final case class Authenticate(userId: String) extends Command
final case class RecordActivity(path: String) extends Command
case object Expire extends Command
private final case class State(
authenticatedUser: Option[String],
lastPath: Option[String],
expired: Boolean
)
def apply(): Behavior[Command] =
running(State(None, None, expired = false))
private def running(state: State): Behavior[Command] =
Behaviors.receiveMessage {
case Authenticate(userId) if !state.expired =>
running(state.copy(authenticatedUser = Some(userId)))
case RecordActivity(path) if !state.expired =>
running(state.copy(lastPath = Some(path)))
case Expire =>
running(state.copy(expired = true))
case _ =>
Behaviors.same
}
}
This is still a simple example, but the shape matters more than the details.
The actor does not expose setters. It does not rely on a synchronized mutable object shared across several threads. Instead, each message is interpreted in the context of the current state, and the next behavior is returned explicitly.
That is how actors model change over time.
State Transitions Should Be Easy to Follow
If you only remember one lesson from this topic, it should be this: the value of an actor is not that it can hide complexity. The value is that it can make state transitions visible.
When people write hard-to-maintain actor code, it is usually because transitions are implicit. State changes happen through scattered side effects. Several messages update overlapping maps. Timers send vaguely named internal commands. The actor technically works, but nobody can explain what happens after the third retry, the first timeout, or the second out-of-order message.
The fix is not more abstractions. The fix is clearer transitions.
For most actors, that means:
- keep the state shape small
- name state transitions in business terms
- make internal messages explicit
- separate public protocol from private workflow messages
- prefer a few clear behavior modes over one giant handler with many boolean flags
If an actor represents something with recognizable phases, model the phases directly.
A Session Actor: Active, Idle, and Expired
Session management is a good example because time matters as much as input. A session is not just data. It changes meaning depending on what has happened recently.
Suppose an API gateway or websocket backend wants to track one user session per actor. The session should:
- start unauthenticated
- become active after login
- record user activity
- expire after inactivity
- reject further activity after expiration
This is a natural fit for behavior-driven design.
import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.scaladsl.{ActorContext, Behaviors, TimerScheduler}
import scala.concurrent.duration.*
object UserSession {
sealed trait Command
final case class Login(userId: String, replyTo: ActorRef[Response]) extends Command
final case class Touch(path: String) extends Command
final case class GetSnapshot(replyTo: ActorRef[Snapshot]) extends Command
private case object IdleTimeout extends Command
sealed trait Response
final case class Accepted(userId: String) extends Response
final case class Rejected(reason: String) extends Response
final case class Snapshot(
userId: Option[String],
lastPath: Option[String],
expired: Boolean
)
private final case class SessionState(
userId: Option[String],
lastPath: Option[String]
)
private val SessionTtl = 15.minutes
def apply(): Behavior[Command] =
Behaviors.withTimers { timers =>
anonymous(timers)
}
private def anonymous(timers: TimerScheduler[Command]): Behavior[Command] =
Behaviors.receiveMessage {
case Login(userId, replyTo) =>
timers.startSingleTimer(IdleTimeout, IdleTimeout, SessionTtl)
replyTo ! Accepted(userId)
active(SessionState(Some(userId), None), timers)
case GetSnapshot(replyTo) =>
replyTo ! Snapshot(None, None, expired = false)
Behaviors.same
case Touch(_) =>
Behaviors.same
case IdleTimeout =>
Behaviors.same
}
private def active(
state: SessionState,
timers: TimerScheduler[Command]
): Behavior[Command] =
Behaviors.receiveMessage {
case Login(_, replyTo) =>
replyTo ! Rejected("Session already authenticated")
Behaviors.same
case Touch(path) =>
timers.startSingleTimer(IdleTimeout, IdleTimeout, SessionTtl)
active(state.copy(lastPath = Some(path)), timers)
case GetSnapshot(replyTo) =>
replyTo ! Snapshot(state.userId, state.lastPath, expired = false)
Behaviors.same
case IdleTimeout =>
expired(state)
}
private def expired(previousState: SessionState): Behavior[Command] =
Behaviors.receiveMessage {
case GetSnapshot(replyTo) =>
replyTo ! Snapshot(previousState.userId, previousState.lastPath, expired = true)
Behaviors.same
case Login(_, replyTo) =>
replyTo ! Rejected("Session has expired")
Behaviors.same
case Touch(_) =>
Behaviors.same
case IdleTimeout =>
Behaviors.same
}
}
This example is useful because it shows all three dimensions together:
- state: who the user is and what the last activity was
- behavior: anonymous, active, and expired modes
- time: idle expiry arrives as an ordinary message
That last point is worth emphasizing. In Akka, time should usually enter the system through messages, not through hidden sleeps or external threads mutating shared state. A timeout is just another event the actor has to interpret.
Time Is Part of the Domain, Not a Side Channel
Many systems behave incorrectly not because the business logic is wrong, but because time is treated as an afterthought.
Common examples:
- a session is valid for fifteen minutes
- a payment must complete within thirty seconds
- a cart should expire after inactivity
- a retry should happen after backoff
- a rate-limit window resets every second or every minute
If you treat those rules as side-channel concerns handled by unrelated scheduled jobs or ad hoc callback code, the workflow becomes harder to reason about. The actor may say one thing about its state while some other thread is about to update it asynchronously.
The actor model works better when time-based events are made explicit in the protocol:
IdleTimeoutRetryDelivery(attempt)ReservationExpiredWindowReset
Once those events are messages, the actor can make consistent decisions using the same state-transition logic it uses for business input.
That gives you one coherent story for what can happen next.
Rate Limiting Is Really State Plus Time
Rate limiting is another strong example because it looks simple until the system is under pressure. The real problem is not counting requests. The real problem is deciding what should happen over time for each client, tenant, or API key.
Suppose a gateway allows five requests per second per customer. That rule has two moving parts:
- current token count
- periodic refill of the budget
That is naturally modeled as actor-owned state plus timed refill messages.
import akka.actor.typed.{ActorRef, Behavior}
import akka.actor.typed.scaladsl.{Behaviors, TimerScheduler}
import scala.concurrent.duration.*
object RateLimiter {
sealed trait Command
final case class AllowRequest(replyTo: ActorRef[Decision]) extends Command
private case object Refill extends Command
sealed trait Decision
case object Allowed extends Decision
final case class Denied(retryAfter: FiniteDuration) extends Decision
private final case class State(tokens: Int, capacity: Int)
def apply(capacity: Int, refillEvery: FiniteDuration): Behavior[Command] =
Behaviors.withTimers { timers =>
timers.startTimerAtFixedRate(Refill, refillEvery)
running(State(tokens = capacity, capacity = capacity), refillEvery)
}
private def running(
state: State,
refillEvery: FiniteDuration
): Behavior[Command] =
Behaviors.receiveMessage {
case AllowRequest(replyTo) if state.tokens > 0 =>
replyTo ! Allowed
running(state.copy(tokens = state.tokens - 1), refillEvery)
case AllowRequest(replyTo) =>
replyTo ! Denied(refillEvery)
Behaviors.same
case Refill =>
running(state.copy(tokens = state.capacity), refillEvery)
}
}
This is still a simplified limiter, but it shows the right architectural idea.
You do not need a globally shared mutable counter and external scheduler racing to update it. One actor owns the current rate-limit state for one scope of identity, and refill happens through a timed command.
That is much easier to test and easier to explain during incident analysis.
Shopping Cart Workflows Benefit From Explicit Modes
Another common domain pattern is the cart or order draft that changes phase over time.
At first the cart is open. Items can be added or removed. Then checkout begins. After that, mutation should stop. Later the cart may be confirmed, abandoned, or expired.
You can model all of that with a single mutable object and a set of status flags. Many systems do. The problem is that the legal operations now depend on combinations of booleans, timestamps, and guard clauses spread across several services.
An actor often lets you model the workflow more honestly:
opencheckingOutclosedexpired
That does not mean every cart system must be an actor. The important point is conceptual: if your domain clearly has phases, behavior-specific modes are often cleaner than one handler that accepts every message and rejects half of them using conditionals.
A sketch might look like this:
object ShoppingCart {
sealed trait Command
final case class AddItem(sku: String, quantity: Int) extends Command
final case class RemoveItem(sku: String) extends Command
final case class BeginCheckout(replyTo: ActorRef[CheckoutResult]) extends Command
case object ExpireCart extends Command
sealed trait CheckoutResult
case object CheckoutStarted extends CheckoutResult
final case class CheckoutRejected(reason: String) extends CheckoutResult
private final case class CartState(items: Map[String, Int])
def apply(): Behavior[Command] =
open(CartState(Map.empty))
private def open(state: CartState): Behavior[Command] =
Behaviors.receiveMessage {
case AddItem(sku, quantity) =>
val newQuantity = state.items.getOrElse(sku, 0) + quantity
open(state.copy(items = state.items.updated(sku, newQuantity)))
case RemoveItem(sku) =>
open(state.copy(items = state.items - sku))
case BeginCheckout(replyTo) if state.items.nonEmpty =>
replyTo ! CheckoutStarted
checkingOut(state)
case BeginCheckout(replyTo) =>
replyTo ! CheckoutRejected("Cart is empty")
Behaviors.same
case ExpireCart =>
expired(state)
}
private def checkingOut(state: CartState): Behavior[Command] =
Behaviors.receiveMessage {
case AddItem(_, _) | RemoveItem(_) =>
Behaviors.same
case ExpireCart =>
expired(state)
case BeginCheckout(replyTo) =>
replyTo ! CheckoutRejected("Checkout already in progress")
Behaviors.same
}
private def expired(previousState: CartState): Behavior[Command] =
Behaviors.receiveMessage {
case _ =>
Behaviors.same
}
}
Again, the exact syntax matters less than the design. The actor is acting like a small workflow engine for one cart. Its current behavior reflects its current business phase.
Avoid Giant State Bags With Many Flags
Teams new to actors often write code that technically uses Akka but keeps the old design habits. The actor ends up with a large state object like this:
isActiveisExpiredisRetryingisClosinghasPendingWritehasTimedOutawaitingConfirmation
Once you have enough flags, you are back in the same place as shared mutable service code: you can no longer see the workflow clearly.
That is usually a sign that the actor wants one of these refactorings:
- split one big actor into smaller collaborating actors
- replace boolean combinations with named behavior modes
- make internal workflow messages more explicit
- separate transient technical state from actual business state
Good actor code is rarely the code with the fewest lines. It is the code where you can explain, in plain English, what states exist and what events cause transitions.
Use Internal Messages for Time and Workflow Steps
One practical pattern shows up repeatedly in production Akka code: public commands stay business-focused, while internal commands drive the workflow.
For example, a session actor may expose:
LoginTouchLogoutGetSnapshot
Internally, it may also handle:
IdleTimeoutTokenRefreshFailedPersistedCleanupFinished
That separation matters because it keeps the public protocol stable while allowing the actor to coordinate its internal state machine.
If you mix all of that together without discipline, the protocol becomes hard to read. If you hide internal steps too aggressively, the workflow becomes mysterious. The balance is to keep internal messages explicit but clearly private.
Side Effects Should Follow State Decisions, Not Replace Them
Another common mistake is to let asynchronous side effects drive the design instead of the state machine.
For example, suppose a cart actor begins checkout and then makes an external call to reserve inventory. A weak design says, "call the external service and update some fields when the callback arrives." A stronger design says:
- transition the actor into a
checkingOutbehavior - send the inventory reservation request
- wait for a success, failure, or timeout message
- transition again based on that result
This difference is subtle but important.
The actor should remain the source of truth for workflow progression. External work produces messages. Those messages do not bypass the state machine; they feed it.
That is how the design stays coherent even when latency, retries, and failure enter the picture.
Testing Gets Easier When Time Is Explicit
One reason this modeling style matters is testability.
If an actor's behavior depends on hidden mutable state and background scheduling that lives somewhere else, tests become awkward. You end up sleeping, polling, or reaching into internal state to guess what happened.
If time and workflow steps are represented as messages, tests become much cleaner. You can assert things like:
- given an active session, when
IdleTimeoutarrives, the actor becomes expired - given a full rate-limit budget, five requests are allowed and the sixth is denied
- given an open cart,
BeginCheckoutmoves it into the checkout phase
That is a better foundation for production code because operational reasoning and test reasoning start to match.
The Real Goal: Predictable State Over Time
This is the deeper engineering value of actors. They are not mainly about avoiding threads. They are about making state changes predictable over time.
That matters most in systems where:
- work is ongoing instead of one-shot
- entities have identity and lifecycle
- business rules depend on sequence and timing
- failure and timeout are normal events
- you need to explain system behavior under load, not just under happy-path demos
When you model the workflow as explicit state plus explicit events, Akka becomes much easier to justify. You are no longer using actors because they sound advanced. You are using them because the domain actually behaves like a collection of stateful participants reacting to messages over time.
Summary
Actors are at their best when you treat them as evolving state machines, not as async objects with a mailbox attached. The key ideas are straightforward:
- state is private and actor-owned
- behavior depends on current state
- time enters through explicit messages such as timeouts and refill ticks
- workflows become clearer when phases are modeled directly
- side effects should feed the state machine, not replace it
If you keep those principles in view, actors stay readable even as the workflow gets more realistic.
In the next lesson, that discipline becomes even more important. Once stateful actors are talking to real dependencies and failures start happening in normal operation, you need a clear strategy for supervision, recovery, and deciding which failures should restart work and which should stop it.
Comments
Be the first to comment on this lesson!