Skip to content

State Machine

Problem

Your actor has distinct lifecycle phases, and the set of valid operations changes between them. A common first approach is to track the phase with a flag and check it in every behavior.

Consider an actor that accepts data while open and should stop accepting writes after being closed. Our first pass might look like:

actor Writer
  var _out: (OutStream | None)
  var _open: Bool = true

  new create(out: OutStream) =>
    _out = out

  be write(data: String) =>
    if _open then
      match _out
      | let o: OutStream => o.print(data)
      end
    end

  be close() =>
    _open = false
    _out = None

This works, but there are a couple of problems lurking.

The _out field is declared as (OutStream | None) so we can release it on close. That means every behavior that touches _out has to match on it, even though we know it’s always a valid OutStream when the writer is open. The compiler doesn’t know that — as far as it’s concerned, _out could be None at any time.

Every behavior also needs the if _open guard. With two behaviors that’s manageable. With five or ten, the same check is scattered everywhere. The flag and the optional type are related — _open being true implies _out is an OutStream — but nothing in the type system connects them. Forget a guard in one behavior and you’ve got a bug that the compiler won’t catch.

Solution

Rather than track the phase with a flag, represent each phase as its own type. Define an interface (or trait) for the operations the actor supports, and have each phase implement the behavior that’s valid for it.

interface _WriterState
  fun write(w: Writer ref, data: String)
  fun close(w: Writer ref)

class _OpenWriter is _WriterState
  let _out: OutStream

  new create(out: OutStream) =>
    _out = out

  fun write(w: Writer ref, data: String) =>
    _out.print(data)

  fun close(w: Writer ref) =>
    w.state = _ClosedWriter

class _ClosedWriter is _WriterState
  fun write(w: Writer ref, data: String) =>
    None

  fun close(w: Writer ref) =>
    None

actor Writer
  var state: _WriterState

  new create(out: OutStream) =>
    state = _OpenWriter(out)

  be write(data: String) =>
    state.write(this, data)

  be close() =>
    state.close(this)

actor Main
  new create(env: Env) =>
    let w = Writer(env.out)
    w.write("hello")
    w.write("world")
    w.close()
    w.write("this will be silently dropped")

Let’s walk through what changed.

interface _WriterState
  fun write(w: Writer ref, data: String)
  fun close(w: Writer ref)

We define an interface with every operation the actor supports. Each method takes the actor as a ref so that the state object can modify the actor’s fields — including replacing itself with a different state.

class _OpenWriter is _WriterState
  let _out: OutStream

The open state holds let _out: OutStream. No None, no matching. If we’re in this state, we have a valid output stream. That’s what it means to be open.

class _ClosedWriter is _WriterState
  fun write(w: Writer ref, data: String) =>
    None

The closed state holds nothing. It doesn’t need an output stream — it has nothing to write to. Writes are silently dropped.

  fun close(w: Writer ref) =>
    w.state = _ClosedWriter

State transitions happen by assigning a new state object to w.state. When _OpenWriter.close fires, it replaces itself with a _ClosedWriter. The output stream goes out of scope along with the old state — no need to manually set anything to None.

actor Writer
  var state: _WriterState

  be write(data: String) =>
    state.write(this, data)

The actor itself becomes a thin shell. Each behavior delegates to the current state, passing this so the state can trigger transitions. No flags, no matching on optional types.

Discussion

This pattern is driven by two ideas.

Each phase is a distinct type carrying exactly the data it needs. _OpenWriter holds the output stream; _ClosedWriter doesn’t. There’s no (OutStream | None) — the data that exists in a phase is defined by the type that represents that phase.

Invalid operations get isolated implementations. When you write to a closed writer, you hit _ClosedWriter.write — a no-op. You could just as easily have it log a warning, notify the caller, or panic. The point is that each operation’s behavior in each phase lives in its own method on its own type, not tangled up with flag checks.

With just two states, this might feel like a lot of ceremony. The payoff comes when states multiply. Each new state is a new class implementing the interface — you don’t touch existing states or add branches to existing methods. When the number of states grows, you can introduce traits with default implementations for groups of operations, so new states only need to override the methods that are meaningful for them.

This pattern also nests naturally. A state can hold its own sub-state machine for finer-grained lifecycle management within a single phase.

For a real-world example of this pattern at scale, see the ponylang/postgres driver. Its Session actor uses a _SessionState interface with six concrete states spanning the full connection lifecycle, from unopened through SSL negotiation, authentication, and query processing. The logged-in state contains a nested _QueryState machine tracking query lifecycle. A trait hierarchy provides default implementations for invalid transitions, so each state class only needs to implement what’s actually valid for it.