| name | actor-pattern |
| description | Apply the actor pattern for thread-safe state management. Use when: (1) multiple threads/goroutines need shared mutable state, (2) replacing mutex-locked structs or atomic patterns, (3) building a service layer that owns in-memory state plus persistence, (4) application has distinct operational modes (setup, running, error) requiring a coordinator that manages sub-actors, (5) the user mentions 'actor pattern', 'goroutine event loop', 'channel-based state management', or 'actors managing actors'. Supports Go, Jai, C, Rust, Odin, and Zig. |
Actor Pattern
Replace locks and atomics with a single thread/goroutine that owns all mutable state, accessed via channel-based request/response.
Why This Should Be a First-Reach Tool
This pattern is language-agnostic and highly portable. It has been implemented and verified across Go, Jai, C, Rust, Odin, and Zig — and the translation between any two is mechanical. Consider it early in design decisions involving concurrent state access, before reaching for mutexes or atomics.
The only building block is a bounded blocking queue (channel). Languages split into three tiers:
- First-class channels (Go, Odin): the pattern maps 1:1, including
select for multiplexing.
- Typed channels, no select (Rust
std::sync::mpsc, Jai): shutdown uses channel close semantics instead of select. One-shot reply channels (Rust) or reply pointers + done channels (Jai) handle the response path.
- No channels (C, Zig): a channel is just mutex + 2 condition variables + ring buffer — 75–100 lines. The abstraction is thin sugar over universal OS primitives.
The actor pattern itself never changes. The only thing that varies is how you spell "send a tagged message and block for the reply." Any language with threads and a mutex can do this.
When to Apply
Single actor:
- Multiple threads (HTTP handlers, background jobs) need to read/write shared state
- Current code uses mutexes or atomics to protect state
- You need a service that combines in-memory caching with persistent storage
Actor hierarchy (coordinator + sub-actors):
- Application has distinct operational modes (setup wizard, running, error recovery)
- Sub-actors need to trigger their own replacement (e.g., setup → running)
- You need graceful error recovery with fallback chains
References & Runnable Examples
Each compendium has 4 examples: basic counter, KV store, polling, actor hierarchy.
Go detailed reference: references/go-patterns.md — complete code templates and design principles.
Implementation Steps — Single Actor
- Define the public interface (what callers see)
- Define command tags (enum of operations)
- Define the command struct with payload fields and a result/reply mechanism
- Implement the actor struct with a command channel and dispatch thread
- Write the dispatch loop — single
for/while loop owning all mutable state as locals
- Write public methods that send commands and block for results
- Wire shutdown — close the command channel; dispatch loop exits
Implementation Steps — Actor Hierarchy
- Define Stoppable interface and StateBuilder function type
- Build the coordinator that owns one sub-actor at a time
- Make SetState fire-and-forget — never synchronous, or you get deadlocks
- Define RecoverableError for fallback chains; terminal ErrorApp as backstop
- Build each sub-actor implementing Stoppable
- Sub-actors receive coordinator pointer; call
SetState(nextBuilder) to trigger transitions
- Callers type-switch on the current sub-actor to decide behavior
Command shape: tagged structs, not interface.apply()
The naive "command pattern" approach is one interface type per
command with an apply() method:
type cacheCmd interface{ apply(*cacheState) }
type getAllCmd struct{ result chan []Release }
func (c *getAllCmd) apply(s *cacheState) { }
This spreads the state-mutation logic across types. Prefer a tagged
struct with all logic co-located in run() as local closures that
capture state variables:
type cmdTag int
const (cmdGetAll cmdTag = iota; cmdInvalidate; cmdStats)
type cacheCmd struct {
tag cmdTag
result chan cacheResult
}
func (c *Cache) run(ctx context.Context) {
var (warm bool; releases []Release; fetchedAt time.Time; lastErr error)
doFetch := func() (int, time.Duration, error) { }
doGetAll := func(cmd cacheCmd) { }
for {
select {
case cmd := <-c.cmds:
switch cmd.tag {
case cmdGetAll: doGetAll(cmd)
}
}
}
}
One function, top-to-bottom readable, state and behavior adjacent.
Testing the actor with deterministic time
When the actor uses periodic refresh or timers, introduce a narrow
Ticker interface rather than using time.Ticker directly:
type Ticker interface {
GetTick() <-chan time.Time
Reset(d time.Duration)
Stop()
}
Production uses a realTicker wrapping time.Ticker. Tests use a
ManualTicker whose Reset() method is unbuffered — calling it
blocks the actor until the test receives. This creates a
rendezvous-style sync barrier:
type ManualTicker struct {
ch chan time.Time
resetCalled chan time.Duration
}
func (m *ManualTicker) Reset(d time.Duration) { m.resetCalled <- d }
func (m *ManualTicker) AwaitReset() time.Duration { return <-m.resetCalled }
Tests then run deterministically without time.Sleep:
ticker.Fire()
ticker.AwaitReset()
assert state is updated
No polling, no races, no arbitrary timeouts. Example: releases.Cache
in idpair-inbound.