Actor State Pattern¶
The "Pathfinder pattern": all mutable domain state lives in a dedicated state class. The actor only routes messages and calls Apply.
Rules¶
- State is a
sealed classin its own<Actor>State.csfile. Not a record, not nested in the actor. - Mutable collections (
List<T>,Dictionary<K,V>,HashSet<T>) are fine. Akka's single-threaded mailbox guarantees safe mutation. - Every public mutation method is named
Apply, overloaded by parameter type. NeverApplyFoo/ApplyBar. - For primitives or multiple values, add a small wrapper record next to the state class and use
Apply(WrapperRecord). Emptyis a property, never a shared static field.- State is never sent to callers. Use
GetSnapshot()to produce a response record andFromSnapshot()to reconstruct. - Persistent actors add
GetPersistenceState()andFromPersistence(). - No domain fields on the actor (dictionaries, flags, counters). Infrastructure refs (actor refs,
CancellationTokenSource, stopwatches) may stay on the actor.
Correct¶
public sealed class CartState
{
private readonly Dictionary<Guid, int> _items = new();
public bool CheckedOut { get; private set; }
public static CartState Empty => new();
public void Apply(ItemAdded evt) =>
_items[evt.ProductId] = _items.GetValueOrDefault(evt.ProductId) + evt.Quantity;
public void Apply(CheckedOut _) => CheckedOut = true;
// wrapper record for multiple values
public void Apply(QuantityChange change) => _items[change.ProductId] = change.Quantity;
public CartSnapshot GetSnapshot() => new(_items.ToImmutableDictionary(), CheckedOut);
public static CartState FromSnapshot(CartSnapshot s)
{
var state = new CartState { CheckedOut = s.CheckedOut };
foreach (var (id, qty) in s.Items) state._items[id] = qty;
return state;
}
}
public sealed record QuantityChange(Guid ProductId, int Quantity);
Persistent variant¶
public PersistedCartState GetPersistenceState() => new(_items.ToDictionary(), CheckedOut);
public static CartState FromPersistence(PersistedCartState p)
{
var state = new CartState { CheckedOut = p.CheckedOut };
foreach (var (id, qty) in p.Items) state._items[id] = qty;
return state;
}
See Persistence for how these hook into Persist and SaveSnapshot.
Anti-patterns¶
Leaked state on the actor
public sealed class CartActor : ReceiveActor
{
private readonly Dictionary<Guid, int> _items = new(); // domain state on actor
private bool _checkedOut; // flag on actor
private int _retries; // counter on actor
}
ActorSystem.
Misnamed or nested state
public sealed class CartActor
{
private sealed record State(ImmutableList<Item> Items); // nested record
}
public sealed class CartState
{
public void ApplyItemAdded(ItemAdded e) { } // not named Apply
public void ApplyCheckedOut() { }
public static readonly CartState Empty = new(); // shared mutable instance
}
Empty is a mutable instance reused across actors: one actor's mutation corrupts the next.
Sending state to callers
Reply with_state.GetSnapshot() instead.
Why a class and not a record?
The state mutates in place and is touched only from the actor's mailbox. Records with
with expressions allocate on every event and encourage leaking immutable-looking
copies that are never actually shared.
Testing benefit¶
State classes are plain objects. Test them directly without an actor system: