Finite state machines in Go. Zero dependencies — standard library only, tests included.
import "github.com/open-ships/statemachine"📖 Reference documentation on pkg.go.dev
A finite state machine is a partial function from (state, event) to state. This package keeps
that function as its core and builds optional state owners around it.
A Machine does not hold the current state. It is an immutable compiled definition. Next
answers a pure question about a value you own and never runs the selected row's effect:
next, err := orders.Next(ctx, order.State, Submit, cmd)Use Next for planning or a row with no Do. To perform an effectful row, use
an Instance, queued Runtime, or Store-backed execution; assigning the Next
result would intentionally skip its effect.
One definition can serve a million aggregates and any number of goroutines. When the package should
own execution state instead, construct one Instance per aggregate. The queued package
adds serialized run-to-completion execution, persist runs the flat definition inside an
adapter-owned unit of work, and statechart supplies hierarchy and lifecycle actions.
A transactional persist.Store can pass its transaction through to effects. The
supervised package adds a strict Issue–Verify protocol, mandatory checks, finite
execution limits, startup reconciliation, and latched faults for safety-adjacent orchestration.
These modules remain separate because they have different concurrency, commit, and failure semantics.
type State string
type Event string
const (
Off State = "off"
On State = "on"
)
const Flip Event = "flip"
var light = statemachine.MustCompile([]statemachine.Transition[State, Event, struct{}]{
{From: Off, Event: Flip, To: On},
{From: On, Event: Flip, To: Off},
})
func main() {
s := Off
s, _ = light.Next(context.Background(), s, Flip, struct{}{})
fmt.Println(s) // on
}States and events are your own defined types with named constants, so Flipp and Of are compile
errors rather than a 3 a.m. page.
// Note the =: an alias, not a defined type, so Compile can infer S, E and T.
type row = statemachine.Transition[State, Event, *Cmd]
var table = []row{
{From: Draft, Event: Submit, To: Pending, Guard: hasLines},
{From: Draft, Event: Cancel, To: Cancelled},
{From: Pending, Event: Pay, To: Paid, Do: charge},
// Two rows share Paid+Ship. The first whose guard applies wins; the second
// is the unguarded default arm.
{From: Paid, Event: Ship, To: Shipped, Guard: inStock},
{From: Paid, Event: Ship, To: Backordered},
}
var orders = statemachine.MustCompile(table)Guards return a reason, not a bool. nil applies, any error declines. Declining is not a
failure — it drops that row and tries the next one, so a trailing unguarded row is a default arm, the
semantics of a switch. Only when no row is left does the reason reach the caller:
_, err := orders.Next(ctx, Delivered, Refund, cmd)
errors.Is(err, statemachine.ErrNotPermitted) // true: the machine refused -> 409
errors.Is(err, ErrWindowClosed) // true: and this is why -> 422That is the whole 409-versus-422 story, with no second error type and no errors.As.
Effects run only behind a state owner. Machine.Next never calls Do. Instance.Fire, a queued
runtime, or Store-backed execution runs the selected effect and publishes the destination only when
Do returns nil. The error comes back unwrapped:
{From: Pending, Event: Pay, To: Paid, Do: func(ctx context.Context, c *Cmd) error {
return c.gateway.Charge(ctx, c.Order.ID, c.Order.Cents) // fails -> stays Pending
}},Affordances come from the same selection rule as execution, so a rendered button and the handler that receives its click cannot disagree about where an event leads:
for event, to := range orders.Permitted(ctx, o.State, cmd) {
fmt.Println(event, "->", to) // ship -> shipped, cancel -> refunded
}Transition[S, E comparable, T any] |
one row: From, Event, To, Guard, Do |
Machine[S, E comparable, T any] |
a compiled table; immutable, safe for concurrent use |
Compile(transitions) |
build a machine, reporting an unreachable row |
MustCompile(transitions) |
the same, panicking — for tables that are program text |
Machine.Next(ctx, from, event, data) |
select and report a destination without running Do |
Machine.Permitted(ctx, from, data) |
iterate the events accepted now, each with its destination |
ErrNotPermitted |
the sentinel every refusal wraps |
The optional state-owning interface adds:
NewInstance(machine, initial) |
create one fail-fast, in-memory execution |
NewInstanceWithObservers(machine, initial, observers...) |
create an execution that reports committed node exits and entries |
Instance.State() |
read its last committed state |
Instance.Fire(ctx, event, data) |
apply one event without passing the state |
Instance.Permitted(ctx, data) |
eagerly snapshot its current affordances |
ErrInFlight |
another fire is already executing on that instance |
An Observation names one exited or entered state. Its Seq is consecutive
within one observed execution, Step groups the non-empty position change made
by one event, Remaining == 0 closes that Step, and At timestamps its committed
publication. Context and T are passed
to the typed Observer separately. Observers run synchronously without an
execution lock, but in isolation: an observer panic or runtime.Goexit cannot
reverse the committed transition and is returned as a post-commit error. Flat self-transitions change no position and are
observation-silent. A shared observer can be called concurrently by different
executions and must synchronize its own census or sink.
T is the value handed to every Guard and Do — your aggregate, plus whatever this command needs.
It is passed to Next or the state-owning execution rather than stored, so one immutable Machine
serves every request while still seeing request-scoped values. A machine with nothing to carry uses
struct{}.
Visualization and reachability checking remain ordinary loops over the flat table. Per-state entry
and exit actions do not belong to a flat Machine; use the statechart package when those semantics
are required. Runnable flat-machine examples are in example_test.go.
| Need | Module | State and concurrency semantics |
|---|---|---|
| Pure planning or explicit assignment with no effects | Machine.Next |
caller-owned value; never runs Do |
| One in-memory aggregate | Instance |
owned state; overlapping fire fails fast |
| Follow-up events and FIFO serialization | queued.Runtime |
owned state; each root run drains to completion |
| Database state and outbox work | persist.Fire |
transactional when the Store supplies a transaction; never auto-retried |
| Hierarchy, initial substates, entry/exit, reentry | statechart |
immutable chart plus one stateful instance per aggregate |
| Explicit issue/verification and latched faults | supervised.Supervisor |
stopped until reconciled; one bounded Attempt; destination commits only after verification |
The supervised module is not a safety-rated controller. It cannot terminate a callback that ignores
cancellation or replace independent emergency stop, safe torque off, guarding, collision protection,
or human-presence separation. Its detailed operating assumptions and prohibited uses are in
SAFETY.md. Its strict Machine exposes states, events, transition metadata, explicit
refusals, and deterministic Graphviz DOT output without exposing callback values.
Supervisor operations return (Result, error), use non-reusable Execution ID plus sequence Attempt
identity, and reject stale Verify calls across restoration. NewWithOptions adds deterministic Clock,
durable pre-Issue Journal, and ordered lifecycle Recorder seams; asynchronous expiry and secondary
causes remain available through Records after recovery.
A queued callback schedules same-runtime follow-ups with runtime.Enqueue and the context it was
given. Naming the Runtime makes cross-Runtime mistakes return ErrWrongRuntime; finite limits bound
outstanding roots and cumulative Run work. It must never synchronously call Runtime.Fire;
replacing that context defeats deadlock detection.
Statechart inspection is split between immutable definition facts and one
execution's current position. Chart.States enumerates compiled states,
Chart.Arrows(source) exposes every statically possible inherited transition
without running Guards, and Chart.Position(active) projects an exact loaded
state. Instance.Position takes the same atomic committed-state snapshot as
State. A Position classifies every declared state as inactive, enclosing, or
the exact active state. The original Definition remains the source for
declared parent and initial edges; the Chart API does not duplicate those
relationships.
Machine.Next never runs Do. Effectful execution is available only through a state owner, so
discarding a pure query cannot leave an external effect behind. A state-owning Instance.Fire may
discard its returned state because the Instance has already committed it, but its error still must
be handled.
An Instance or queued runtime is the sole owner of its state. Do not also keep an authoritative
copy in the value passed as T. Effects can still be partial: the state owner guarantees its state
transition, not rollback of arbitrary I/O. Its synchronization protects the owned state, not other
mutable fields inside T.
For persistence, a getter and setter are not a transaction. A persist.Store must use a conditional
write and must invoke the transition callback exactly once after a successful load. A transactional
database Store should put aggregate changes and an outbox record in the same unit of work. Only work
performed through that transaction is atomic. A conflict discovered after an effect is not retried;
an application retry must reload and use a stable idempotency key. persist.Fire and persist.Step
detect an Adapter that repeats or omits the callback and never execute a repeated transition step.
persist.Step additionally reports the loaded From and attempted To, but only
StepResult.Confirmed says the Store returned success; false is not proof that
an external commit did not happen.
The rest — guard purity, how ErrNotPermitted propagates through nested machines, why S and E
must be strictly comparable, and why generated tables want Compile rather than MustCompile — are
documented in full on
pkg.go.dev.
Apple M1 Pro, Go 1.26, measured with testing.B.Loop. Nothing on the flat Machine's successful
path allocates: Next is a map lookup, a guard call and a return, and Permitted's lazy iterator
stays on the stack. A refusal allocates only the error. The state-owning modules intentionally add
synchronization and, where required, eager snapshots or queued work.
BenchmarkNextAccepted-10 44001490 27.18 ns/op 0 B/op 0 allocs/op
BenchmarkNextDefaultArm-10 61790773 19.42 ns/op 0 B/op 0 allocs/op
BenchmarkNextRefused-10 25778685 46.09 ns/op 80 B/op 2 allocs/op
BenchmarkRefusalErrorsIs-10 132888273 9.03 ns/op 0 B/op 0 allocs/op
BenchmarkPermitted-10 30415488 39.53 ns/op 0 B/op 0 allocs/op
Go 1.26 is what makes Permitted free: the iter.Seq2 it returns closes over the machine, the state
and the data, and through Go 1.25 that closure and the yield function both escaped to the heap — 88 B
and 3 allocations for every call. Escape analysis in 1.26 keeps them on the stack, which is a fourfold
improvement and the reason this module requires it.
Semi-inspired by looplab/fsm, and distilled from it. The flat
Machine's main departures: states and events are typed rather than strings; callbacks are fields on
a row rather than a map[string]Callback keyed by magic strings like "before_open"; guards and
effects return error rather than calling Event.Cancel; payloads are a type parameter rather than
...interface{}; and the immutable definition does not hold current state. Instance, queued,
persist, and statechart add stateful execution without changing that definition.
Requires Go 1.26, which is what keeps Permitted allocation-free.
MIT