fate: A Statechart Engine for Go
Harel statecharts in Go — hierarchy, parallel regions, history, guards and delayed transitions. Zero dependencies, deterministic, and snapshots that restore exactly, including inside Temporal workflows.
fate is an implementation of Harel statecharts in Go: nested states, parallel regions, shallow and deep history. Not a flat finite automaton. The semantics follow SCXML and XState v5, expressed with generics so context and events are strongly typed.
import "github.com/arisros/fate"
Two rules that shaped everything
First: zero dependencies. The core package imports only the standard library. That isn’t a style preference — it’s what keeps go get github.com/arisros/fate from ever pulling in net/http, it’s why the Temporal integration is a separate module (fate/temporal), and it’s why fate-studio lives in a different repository entirely.
That rule also forced the big decision in v0.5.0: the renderers and the diff moved out of the root package into fate/render and fate/diff. It’s an API break — and flagged as one in the changelog — but the result is that anyone who only needs the engine no longer carries visualisation code along with it.
Second: determinism. A Machine is immutable and shareable; the Actor holds the state, and that state serialises to JSON and restores byte for byte:
blob, _ := a.Persist()
b, _ := fate.NewActorFromSnapshot[Ctx, Evt](m, blob)
The engine doesn’t decide what time it is
This is the part that shaped the design most. Delayed (after) transitions don’t call time.After inside the engine. The engine only reports which timers are outstanding, and something outside decides when they fire:
for _, pt := range a.PendingTimers() {
a.FireTimer(pt.ID)
}
That looks roundabout until you remember who the consumer is. Inside a Temporal workflow, reading a real clock is forbidden — a replay has to produce identical results. With timers pulled rather than pushed, the core is blind to the clock and safe in any deterministic environment. The same pattern covers invocations: PendingInvocations / ResolveInvocation.
What’s in it
| Statechart concept | In fate |
|---|---|
| Atomic / compound / parallel / final states | NodeAtomic / NodeCompound / NodeParallel / NodeFinal |
| Shallow & deep history | NodeHistory with HistoryShallow / HistoryDeep |
| Guards | TransitionConfig.Guard plus And / Or / Not / StateIn combinators |
| Entry, exit and transition actions | Assign, Raise, Log, EnqueueActions |
| Delayed transitions | StateNodeConfig.After |
| Invoked or spawned child actors | StateNodeConfig.Invoke |
| Persistence | Actor.Persist / NewActorFromSnapshot |
The hardest part lives in algorithm.go, which adapts the SCXML transition algorithm: find the LCCA (least common compound ancestor) of source and target, then build the exit set deepest-first and the entry set outermost-first. That ordering isn’t cosmetic — it determines the order exit and entry actions run in, and getting it wrong means actions running against states that are no longer active.
There’s also parity_test.go, which checks this engine’s behaviour against what XState’s semantics say should happen. When they disagree, the thing that’s wrong is almost always my assumption, not the spec.
Status
Still v0.x — the API can change between minor releases until v1.0.0, and breaking changes are always flagged explicitly in CHANGELOG.md.
There’s also a small CLI (go install github.com/arisros/fate/cmd/fate@latest) for rendering statecharts to ASCII, Mermaid or a JSON graph, and for diffing two snapshots.