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:

  • IdleTimeout
  • RetryDelivery(attempt)
  • ReservationExpired
  • WindowReset

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:

  • open
  • checkingOut
  • closed
  • expired

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:

  • isActive
  • isExpired
  • isRetrying
  • isClosing
  • hasPendingWrite
  • hasTimedOut
  • awaitingConfirmation

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:

  • Login
  • Touch
  • Logout
  • GetSnapshot

Internally, it may also handle:

  • IdleTimeout
  • TokenRefreshFailed
  • Persisted
  • CleanupFinished

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:

  1. transition the actor into a checkingOut behavior
  2. send the inventory reservation request
  3. wait for a success, failure, or timeout message
  4. 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 IdleTimeout arrives, the actor becomes expired
  • given a full rate-limit budget, five requests are allowed and the sixth is denied
  • given an open cart, BeginCheckout moves 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.