A small, generic finite state machine library for Go. Named after my wife Kry.
go get github.com/rianby64/krymachine, err := kry.Build[State, Param](initialState).
GoingTo(Approved,
kry.From[State, Param](InReview).On("Approve", kry.OnEnter(notifyApproval)),
).
GoingTo(Rejected,
kry.From[State, Param](InReview).On("Reject", kry.OnEnterVariadic(notifyRejection)),
).
WithFullHistory().
Create()
err = machine.Apply(ctx, Approved, ApprovalParam{Reviewer: "alice"})- Generic.
FSM[State comparable, Param any]— states and params are typed. - Single trigger.
Apply(ctx, dst, params...)— no second event API to learn. - Two arity rules.
OnEnter(exactly 1 param, strict) andOnEnterVariadic(any number, lenient). - Fuzzy matching.
FnFrom/FnGoingToaccept predicates instead of state lists. - History, panic handling, loop detection, escape hatches all built in.
Both APIs are first-class and produce the same FSM. Pick whichever fits the situation.
import kry "github.com/rianby64/kry"
machine, err := kry.Build[WorkflowState, WorkflowParam](Draft).
// exact src, exact dst
GoingTo(InReview,
kry.From[WorkflowState, WorkflowParam](Draft).On("Submit", kry.OnEnter(notifyReviewer)),
).
// multiple sources to one dst
GoingTo(Approved,
kry.From[WorkflowState, WorkflowParam](InReview1, InReview2, InReview3).
On("Approve", kry.OnEnter(notifyApproval)),
).
// FnSrc, exact dst — "any in-review state can be retracted"
GoingTo(Draft,
kry.FnFrom[WorkflowState, WorkflowParam](func(s WorkflowState) bool {
return s >= InReview1 && s <= InReview3
}).On("Retract", kry.OnEnterVariadic(notifyRetraction)),
).
// exact src, FnDst — "Rejected can reopen to any review level"
FnGoingTo(func(s WorkflowState) bool {
return s >= InReview1 && s <= InReview3
},
kry.From[WorkflowState, WorkflowParam](Rejected).On("Reopen", kry.OnEnter(notifyReopen)),
).
// no-op transition (no callback)
GoingTo(Archived,
kry.From[WorkflowState, WorkflowParam](Approved, Rejected).
On("Archive", kry.NoOp[WorkflowState, WorkflowParam]()),
).
WithFullHistory().
WithPanicHandler(myPanicHandler).
Create()Each GoingTo / FnGoingTo block corresponds to one node in the state diagram and its incoming edges.
The original New() constructor is still available for cases where you want to build the transition table programmatically:
machine, err := kry.New(Draft, []kry.Transition[WorkflowState, WorkflowParam]{
{
Name: "Submit",
Src: []WorkflowState{Draft},
Dst: InReview,
Enter: kry.OnEnter(notifyReviewer),
},
// ...
}, kry.WithFullHistory[WorkflowParam]())Each transition carries exactly one callback. The constructor name declares the arity.
| Constructor | Fires when | Wrong-arity call |
|---|---|---|
kry.OnEnter(fn) |
len(params) == 1 |
returns ErrNotAllowed |
kry.OnEnterVariadic(fn) |
any number of params | passes them all through |
kry.NoOp[State, Param]() |
always, does nothing | accepts any params |
Arity is enforced strictly for OnEnter. Calling Apply(ctx, dst) with zero or two params against an OnEnter transition returns ErrNotAllowed and the state does not change. This catches misuse at the call site instead of silently dropping params.
// strict — must be called with exactly one ApprovalParam
kry.OnEnter(func(ctx context.Context, fsm kry.InstanceFSM[State, ApprovalParam], p ApprovalParam) error {
return notify(p.Reviewer)
})
// lenient — works with zero, one, or many params
kry.OnEnterVariadic(func(ctx context.Context, fsm kry.InstanceFSM[State, ApprovalParam], p ...ApprovalParam) error {
for _, x := range p { _ = x }
return nil
})When Apply(ctx, dst, ...) runs, the dispatcher resolves the matching transition in strict order:
- Exact
From+ exactGoingTo— always wins. FnFrom+ exactGoingTo— first matching wins, in registration order.- Exact
From+FnGoingTo— first matching wins, in registration order. FnFrom+FnGoingTo— first matching wins, in registration order.
Two rules to internalize:
Rule 1 — exact always beats
Fn*. A concrete(From, GoingTo)pair wins over any predicate-based match, regardless of registration order. Concrete beats general.
Rule 2 — among
Fn*matches, registration order is the priority. The first registeredFn*block whose predicates returntruefires. Order them most specific first, fall-throughs last.
Construction-time overlap detection between predicates is not feasible (function equivalence is undecidable in general), so the registration order is the contract.
// SIP-style example — exact wins, then narrow fn, then wide fn
kry.Build[SIPCode, SIPParam](Idle).
// exact — always wins for 200
GoingTo(Confirmed,
kry.From[SIPCode, SIPParam](Code200).On("OK", kry.OnEnterVariadic(handleOK)),
).
// first Fn — fires for 101–199
FnGoingTo(
func(s SIPCode) bool { return s >= 101 && s <= 199 },
kry.FnFrom[SIPCode, SIPParam](anyState).On("Provisional", kry.OnEnterVariadic(handleProvisional)),
).
// second Fn — wider fallback, only reached if the first didn't match
FnGoingTo(
func(s SIPCode) bool { return s >= 100 && s <= 999 },
kry.FnFrom[SIPCode, SIPParam](anyState).On("AnyResponse", kry.OnEnterVariadic(handleAny)),
)- Exact
(From, GoingTo)paths allow at most one handler per pair, regardless of arity. A duplicate registration returnsErrRepeatedat construction time. Fn*paths have no uniqueness constraint — register as many as you like and order them deliberately.
Go generics cannot express different param types per transition on the same Apply method. kry fixes one Param type per machine and recommends a union struct with named constructors:
type CarParam struct {
Speed *float64
Pressure *float64
}
func ForRide(speed float64) CarParam { return CarParam{Speed: &speed} }
func ForStop(p float64) CarParam { return CarParam{Pressure: &p} }
err := car.Apply(ctx, Moving, ForRide(70))
err = car.Apply(ctx, Stopped, ForStop(0.2))Call sites stay readable, history items remain uniform ([]CarParam is easy to log/serialize), and each transition's expected payload is documented by which constructor builds it.
machine, _ := kry.Build[State, Param](initial).
GoingTo(...).
WithFullHistory(). // unbounded
// WithHistory(50). // bounded
// WithEnabledStackTrace(). // capture stack traces on errors
// WithCloneHandler(deepCopyParams). // custom param cloning (e.g. CBOR)
Create()
for _, item := range machine.History() {
fmt.Printf("%s %v→%v err=%v ignored=%v forced=%v\n",
item.Name, item.From, item.To, item.Err, item.Ignored, item.Forced)
}Each HistoryItem carries: Name, From, To, Params, Err, StackTrace, Reason, Ignored, Forced, ForcedTo, ExpectFailed.
Nested Apply calls inside a callback are recorded chronologically under the parent — the parent item appears first, then its children.
Some methods deliberately bend FSM rules. Both are tracked in history so the author always sees when and where the rules were bent.
// Inside an OnEnter / OnEnterVariadic callback:
fsm.IgnoreCurrentTransition() // silently roll back this transition (no error)
err := fsm.ForceState(SomeState) // jump to any registered state, bypassing transitionsHistory flags:
| Flag | Meaning |
|---|---|
item.Ignored |
IgnoreCurrentTransition() was called during this transition |
item.Forced |
ForceState(...) was called during this transition |
item.ForcedTo |
the state ForceState actually set (distinct from item.To, the originally intended destination) |
item.HasViolation() |
Ignored || Forced — single predicate for auditing |
for _, item := range machine.History() {
if item.HasViolation() {
log.Printf("escape hatch used: %+v", item)
}
}ForceState rejects unregistered states with ErrUnknown and can also be called outside an Apply — in which case a standalone history item is recorded.
IgnoreCurrentTransition() and a nested Apply() inside the same callback interact in ways that are likely to surprise:
- Calling
IgnoreCurrentTransition()after the nestedApplyis silently dropped — the inner Apply's defer clears the "running" flag thatIgnoreCurrentTransitionguards on. The outer transition is not rolled back. - Calling
IgnoreCurrentTransition()before the nestedApplyis consumed by the inner call — the inner transition rolls back and is markedIgnored, but the outer proceeds normally.
Until this is fixed, don't mix IgnoreCurrentTransition() with nested Apply() calls in the same callback. See critics-weak-points.md for the planned resolution.
Provide a WithPanicHandler to intercept callback panics. State rolls back, history records the panic with stack trace, and Apply returns nil:
machine, _ := kry.Build[State, Param](initial).
GoingTo(...).
WithPanicHandler(func(ctx context.Context, panicReason any) {
log.Printf("callback panicked: %v", panicReason)
}).
WithFullHistory().
WithEnabledStackTrace().
Create()Without a panic handler the panic is re-thrown after the state is rolled back — the FSM does not swallow panics silently.
kry carries a per-machine loop tracker on the context.Context. If a callback chain attempts the same (machine, from, to) transition twice in the same context tree, Apply returns ErrLoopFound.
The tracker is keyed by machine ID, so multiple FSMs can call into each other through the same context without false positives — only a true cycle in one machine trips it.
machine1.Apply(ctx, open) // calls machine2.Apply(ctx, open)
// which calls machine1.Apply(ctx, open) → ErrLoopFoundAssert that a specific handler fired during Apply. Useful in tests where you want to verify which transition resolved without inspecting state directly:
err := machine.
With(kry.ExpectEnterVariadic(handlerClose)).
Apply(ctx, open)
// The history item carries ExpectFailed=true if a different handler fired.Two helpers produce Graphviz DOT output:
kry.VisualizeStateLinks(transitions) // edges
kry.VisualizeActions(transitions) // subgraphs grouped by transition namefsm.String() returns the full digraph for the constructed machine.
| Error | When |
|---|---|
kry.ErrNotFound |
no transition matches the requested (current, dst) |
kry.ErrUnknown |
ForceState was called with an unregistered state |
kry.ErrRepeated |
two transitions registered for the same exact (Src, Dst) |
kry.ErrLoopFound |
the same transition was attempted twice in the same context |
kry.ErrNotAllowed |
OnEnter (strict) called with a number of params ≠ 1 |
kry does not lock the machine for you. If a single FSM is shared across goroutines, synchronize at the call site (e.g. wrap Apply with a mutex). The internal history keeper is concurrency-safe.
- Go 1.24 or newer.
See example/open-close for the smallest possible machine, and example/elevator for a typed-state, typed-param walkthrough.
MIT.