fsm

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 8, 2026 License: MIT Imports: 5 Imported by: 0

README

fsm

Go Reference CI Release codecov Go Version

A finite state machine for Go. A graph declares the states and the transitions allowed between them. A machine holds one current state and rejects any transition the graph does not list.

States and events are your own named string types, so the compiler rejects an article's states where an order's are expected. No dependencies outside the standard library.

Install

go get github.com/behzadsh/fsm

Requires Go 1.21 or newer.

Two surfaces

A graph belongs to one surface or the other. They share an engine but do not mix.

Simple: name the destination
package main

import (
	"context"
	"fmt"

	"github.com/behzadsh/fsm"
)

type OrderState string

const (
	StateDraft    OrderState = "Draft"
	StateReview   OrderState = "Review"
	StatePaid     OrderState = "Paid"
	StateShipped  OrderState = "Shipped"
	StateCanceled OrderState = "Canceled"
	StateRefunded OrderState = "Refunded"
)

var orderGraph = fsm.NewGraph[OrderState]().
	To(StateDraft, StateReview, StateCanceled).
	To(StateReview, StatePaid, StateCanceled).
	To(StatePaid, StateShipped).
	MustBuild()

func main() {
	ctx := context.Background()

	m := fsm.MustNew(orderGraph, StateDraft)

	if err := m.TransitionTo(ctx, StateReview); err != nil {
		fmt.Println(err)
	}

	fmt.Println(m.Current()) // Review

	if err := m.TransitionTo(ctx, StateShipped); err != nil {
		fmt.Println(err) // fsm: cannot transition Review -> Shipped: invalid transition
	}
}
Labeled: name the action

An action may lead to different states depending on where the machine is. Naming only the destination cannot express that, so the two cancels below need the labeled surface.

type OrderEvent string

const (
	EventSubmit OrderEvent = "submit"
	EventPay    OrderEvent = "pay"
	EventShip   OrderEvent = "ship"
	EventCancel OrderEvent = "cancel"
	EventReject OrderEvent = "reject"
)

var orderGraph = fsm.NewEventGraph[OrderState, OrderEvent]().
	On(StateDraft, EventSubmit, StateReview).
	On(StateDraft, EventCancel, StateCanceled).
	On(StateReview, EventPay, StatePaid).
	On(StatePaid, EventCancel, StateRefunded).
	MustBuild()

m := fsm.MustEventMachine(orderGraph, StateDraft)

err := m.Fire(ctx, EventCancel)

The simple surface is the labeled one with each edge's event name bound to its target state. That binding does not appear in its types, methods, errors, or hook arguments.

Guards and hooks

A transition runs these stages in order:

Stage Side effects Can block Runs during Can*
resolve no yes, when no edge exists yes
Guard must not yes yes
OnExit / OnExitBlocking yes only when registered as blocking no
the state changes no no no
OnEnter yes no no
m.Guard(StatePaid, EventShip, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	if order.Address == "" {
		return ErrNoAddress
	}
	return nil
}).OnExitBlocking(StatePaid, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	return releaseHold(ctx, order)
}).OnEnter(StateShipped, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	return notify(ctx, order)
})

Guards must be free of side effects, because CanFire and CanTransitionTo call them without moving the machine.

Work that should prevent a move when it fails belongs in OnExitBlocking. It runs before the state changes, so a failure leaves nothing to undo.

A guard is registered per edge and a hook per state and phase. Registering again replaces the previous one.

Errors

var (
	ErrInvalidTransition = errors.New("invalid transition")
	ErrReentrant         = errors.New("reentrant call from hook")
	ErrUnknownState      = errors.New("unknown state")
)

The labeled surface returns *TransitionError[S, E] carrying From, To, Event, Phase, and the cause. Moved reports whether the state changed before the failure, which determines whether the call can be retried.

var te *fsm.TransitionError[OrderState, OrderEvent]
if errors.As(err, &te) {
	if te.Moved() {
		log.Warn("order moved, follow-up failed", "err", te.Err)
		return nil
	}
	return err
}

Read Moved rather than comparing Phase. PhaseExit covers both a blocking hook that aborted before the change and a reporting hook that did not stop it.

The simple surface returns errors built with fmt.Errorf, so errors.Is matches the sentinels but errors.As and Moved are unavailable. A structured error there would have to name the event type that surface hides.

Semantics

  • CanFire and CanTransitionTo check the edge and the guard. They report that a move is allowed, not that it will succeed: a blocking exit hook can still abort it.
  • Machines hold no lock and are not safe for concurrent use. Callers synchronize.
  • ForceState sets the state without consulting the graph, guards, or hooks. It is meant for operational repair.
  • A hook cannot move its own machine. Nested calls return ErrReentrant and leave the outer transition alone.
  • A built graph is sealed. Later use of its builder does not change what it allows.
  • New and NewEventMachine reject an initial state the graph does not name.

Going backwards

There is no Rollback. Four different needs hide behind that word:

Need Answer
A backward move that is domain logic Declare the edge: On(StateReview, EventReject, StateDraft)
Undo after a side effect failed Move the work into OnExitBlocking, so it never commits
Restore from storage New(graph, storedState), which validates it
Operational repair ForceState

A declared backward edge is auditable, can be guarded and hooked, and appears in Mermaid() like any other transition.

Seeing the shape

A builder chain does not show the machine at a glance the way a map literal does, so graphs render themselves.

fmt.Println(orderGraph.Mermaid())
// stateDiagram-v2
//     Draft --> Canceled: cancel
//     Draft --> Review: submit
//     Review --> Paid: pay

fmt.Println(orderGraph)
// Draft ---cancel---> Canceled
// Draft ---submit---> Review
// Review ---pay---> Paid

Both outputs are sorted, so they are stable across runs and usable as golden files.

Embedding

type Order struct {
	*fsm.EventMachine[OrderState, OrderEvent]

	ID string
}

func NewOrder(id string) *Order {
	return &Order{EventMachine: fsm.MustEventMachine(orderGraph, StateDraft), ID: id}
}

func (o *Order) IsTerminal() bool {
	return o.Is(StateShipped) || o.Is(StateCanceled) || o.Is(StateRefunded)
}

To restore from storage, pass the stored state to New or NewEventMachine. A machine holds nothing else.

Development

go test ./...                                                    # all tests, including Example output
go test -run TestEventMachineFire ./...                          # one test
go test -race -coverprofile=coverage.out -covermode=atomic ./... # what CI runs
go vet ./...
golangci-lint run                                                # config in .golangci.yml; test files are linted too
golangci-lint fmt ./...                                          # apply gci, gofmt, gofumpt, golines

CI runs every Go minor from the declared floor through the current release, and fails when coverage drops below 95%.

Comments wrap at 120 characters. Tests live in the external fsm_test package, one test file per source file, table-driven with t.Run subtests, plus an Example per exported symbol whose // Output: block go test verifies.

make all runs build, vet, lint, and tests in one go.

Contributing

See CONTRIBUTING.md. Security reports go through private advisories, not issues; see SECURITY.md.

License

MIT. See LICENSE.

Documentation

Overview

Package fsm provides a finite state machine.

A graph declares the states and the transitions allowed between them. A machine holds one current state and applies only the transitions the graph lists. States and events are the caller's own named string types, so the compiler rejects one machine's vocabulary where another's is expected.

The package offers two surfaces. They share an engine but do not mix: a graph belongs to one or the other.

The simple surface names the destination:

var graph = fsm.NewGraph[OrderState]().
	To(StateDraft, StateReview, StateCanceled).
	To(StateReview, StatePaid, StateCanceled).
	MustBuild()

m := fsm.MustNew(graph, StateDraft)
err := m.TransitionTo(ctx, StateReview)

The labeled surface names the action, and the graph decides where it leads. An action may lead to different states depending on where the machine is, which the simple surface cannot express:

var graph = fsm.NewEventGraph[OrderState, OrderEvent]().
	On(StateDraft, EventCancel, StateCanceled).
	On(StatePaid, EventCancel, StateRefunded).
	On(StateReview, EventPay, StatePaid).
	MustBuild()

m := fsm.MustEventMachine(graph, StateDraft)
err := m.Fire(ctx, EventCancel)

Guards and hooks

A transition resolves the edge, consults the guard, runs the exit hook, changes the state, and runs the enter hook, in that order.

A guard reports whether the move is allowed. CanFire and CanTransitionTo call it without moving the machine, so it must not have side effects. An exit hook registered with OnExitBlocking aborts the transition when it fails, before the state changes; one registered with OnExit has its error reported instead. An enter hook runs after the state has changed and cannot stop it.

A guard is registered per edge and a hook per state and phase. Registering again replaces the previous one.

Errors

The labeled surface returns TransitionError, which carries the phase that failed. Its Moved method reports whether the state changed before the failure. The simple surface returns errors built with fmt.Errorf, wrapping the same sentinels; errors.Is matches, errors.As does not.

Limits

CanFire and CanTransitionTo report that a move is allowed, not that it will succeed. They check the edge and the guard. A blocking exit hook can still abort the transition.

A machine holds no lock and is not safe for concurrent use. Callers synchronize.

ForceState sets the state without consulting the graph, guards, or hooks. It is meant for operational repair.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidTransition reports that the graph declares no edge for the attempted move.
	ErrInvalidTransition = errors.New("invalid transition")

	// ErrReentrant reports that a hook tried to move the machine it is running inside.
	ErrReentrant = errors.New("reentrant call from hook")

	// ErrUnknownState reports a state that appears nowhere in the graph.
	ErrUnknownState = errors.New("unknown state")
)

Sentinel errors reported by this package. They are wrapped rather than returned directly, so a caller matching them with errors.Is still receives the detail TransitionError carries.

Example:

if errors.Is(err, fsm.ErrInvalidTransition) {
	// the graph declares no such edge
}

Functions

This section is empty.

Types

type Change

type Change[S ~string] struct {
	// From is the state the machine is leaving.
	From S

	// To is the state the machine is entering.
	To S
}

Change describes one move on the simple surface: the state left and the state entered.

It is what guards and hooks receive there. Unlike Transition it carries no event, since that surface has none.

Example:

func(ctx context.Context, c fsm.Change[OrderState]) error {
	log.Info("moved", "from", c.From, "to", c.To)
	return nil
}

type EventGraph

type EventGraph[S ~string, E ~string] struct {
	// contains filtered or unexported fields
}

EventGraph declares the states of a machine and the labeled transitions between them.

Each edge is identified by the pair (from, event) and leads to exactly one target state. The same event name may lead to different targets from different source states, and several events may reach the same target. A state with no outgoing edge is terminal and need not be declared, since it is a target nothing leaves.

The zero value is an empty graph that allows no transition. Build one with NewEventGraph. A built graph holds its own copy of the edges, so later use of the builder that produced it does not change what the graph allows.

Example:

graph := fsm.NewEventGraph[OrderState, OrderEvent]().
	On(StateDraft, EventSubmit, StateReview).
	MustBuild()

func (EventGraph[S, E]) Mermaid

func (g EventGraph[S, E]) Mermaid() string

Mermaid renders the graph as a Mermaid state diagram, with each event as the edge label.

The output is sorted like String's and pastes into Markdown that supports Mermaid.

Example:

fmt.Println(orderGraph.Mermaid())
// stateDiagram-v2
//     Draft --> Canceled: cancel
//     Draft --> Review: submit
Example
graph := fsm.NewEventGraph[orderState, orderEvent]().
	On(stateDraft, eventSubmit, stateReview).
	On(stateDraft, eventCancel, stateCanceled).
	On(stateReview, eventPay, statePaid).
	MustBuild()

fmt.Println(graph.Mermaid())
Output:
stateDiagram-v2
    Draft --> Canceled: cancel
    Draft --> Review: submit
    Review --> Paid: pay

func (EventGraph[S, E]) String

func (g EventGraph[S, E]) String() string

String renders the graph as one edge per line, so EventGraph satisfies fmt.Stringer.

Edges are sorted by source state and then by event, so the output is stable across calls and usable as a golden file. An empty graph renders as the empty string.

Example:

fmt.Println(orderGraph)
// Draft ---cancel---> Canceled
// Draft ---submit---> Review
// Review ---pay---> Paid

type EventGraphBuilder

type EventGraphBuilder[S ~string, E ~string] struct {
	// contains filtered or unexported fields
}

EventGraphBuilder accumulates edges and the conflicts found while declaring them.

Every method returns the builder, so declarations chain. Conflicts are collected as they are found and reported together by Build or MustBuild.

Example:

builder := fsm.NewEventGraph[OrderState, OrderEvent]()
builder = builder.On(StateDraft, EventSubmit, StateReview)
graph, err := builder.Build()

func NewEventGraph

func NewEventGraph[S, E ~string]() *EventGraphBuilder[S, E]

NewEventGraph returns an empty builder for a graph whose states are of type S and whose events are of type E.

Both type parameters are constrained to ~string, so callers declare their own named string types. The compiler then rejects one machine's states where another's are expected, error messages stay readable, and stored values need no conversion.

Example:

type OrderState string
type OrderEvent string

builder := fsm.NewEventGraph[OrderState, OrderEvent]()
Example
graph := fsm.NewEventGraph[orderState, orderEvent]().
	On(stateDraft, eventSubmit, stateReview).
	On(stateDraft, eventCancel, stateCanceled).
	On(stateReview, eventPay, statePaid).
	MustBuild()

to, ok := graph.TargetForTest(stateDraft, eventSubmit)
fmt.Println(to, ok)

// No edge leaves Paid in this graph, so Paid is terminal.
_, ok = graph.TargetForTest(statePaid, eventShip)
fmt.Println(ok)
Output:
Review true
false

func (*EventGraphBuilder[S, E]) Build

func (b *EventGraphBuilder[S, E]) Build() (EventGraph[S, E], error)

Build returns the declared graph, or every conflict found while declaring it.

Conflicts are joined with errors.Join, so a graph with several mistakes reports all of them at once. Use Build when the graph is assembled conditionally and the error has somewhere to go; use MustBuild for a package-level variable.

The graph is returned even when the error is non-nil, holding the edges declared before each conflict.

Example:

graph, err := builder.Build()
if err != nil {
	// duplicate edge (Draft, submit): -> Review and -> Paid
}
Example
// Build returns the error instead of panicking, for conditional construction.
builder := fsm.NewEventGraph[orderState, orderEvent]().
	On(stateDraft, eventSubmit, stateReview)

allowResubmit := true
if allowResubmit {
	builder = builder.On(stateDraft, eventResubmit, stateReview)
}

graph, err := builder.Build()
if err != nil {
	fmt.Println(err)

	return
}

fmt.Println(graph.EdgeCountForTest())
Output:
2

func (*EventGraphBuilder[S, E]) MustBuild

func (b *EventGraphBuilder[S, E]) MustBuild() EventGraph[S, E]

MustBuild returns the declared graph and panics if any conflict was found.

A graph is static configuration built once at start-up, so a conflict is a programmer mistake rather than a runtime condition. This is the regexp.MustCompile pattern, and it is what a package-level variable needs, since a variable initializer cannot handle an error.

Example:

var orderGraph = fsm.NewEventGraph[OrderState, OrderEvent]().
	On(StateDraft, EventSubmit, StateReview).
	MustBuild()

func (*EventGraphBuilder[S, E]) On

func (b *EventGraphBuilder[S, E]) On(from S, event E, to S) *EventGraphBuilder[S, E]

On declares that firing event while in from moves the machine to.

Declaring the pair (from, event) more than once is a conflict, reported by Build or MustBuild. The first declaration wins, so a later one does not silently replace an earlier edge. Re-declaring an identical edge is a conflict as well.

Example:

builder.On(StateDraft, EventSubmit, StateReview)
// firing EventSubmit in StateDraft now leads to StateReview

type EventMachine

type EventMachine[S ~string, E ~string] struct {
	// contains filtered or unexported fields
}

EventMachine holds one current state and applies only the transitions its graph declares.

It holds no lock and is not safe for concurrent use. Hooks perform real work, often I/O, and an internal mutex would be held across it. Callers synchronize instead.

Build one with NewEventMachine or MustEventMachine.

Example:

m := fsm.MustEventMachine(orderGraph, StateDraft)
err := m.Fire(ctx, EventSubmit)

func MustEventMachine

func MustEventMachine[S, E ~string](graph EventGraph[S, E], initial S) *EventMachine[S, E]

MustEventMachine returns a machine positioned at initial and panics if the graph never names that state.

Use it where the initial state is a constant and a failure would be a programmer mistake. Prefer NewEventMachine when the state came from storage or a request.

Example:

m := fsm.MustEventMachine(orderGraph, StateDraft)

func NewEventMachine

func NewEventMachine[S, E ~string](graph EventGraph[S, E], initial S) (*EventMachine[S, E], error)

NewEventMachine returns a machine positioned at initial, or ErrUnknownState if the graph never names that state.

Construction is where a state read from storage enters the program. Without this check, a state that has drifted out of the graph produces a machine that rejects every transition, with nothing to say why.

Example:

m, err := fsm.NewEventMachine(orderGraph, order.Status)
if err != nil {
	// the stored status is not in the graph
}
Example
m, err := fsm.NewEventMachine(orderGraph(), stateDraft)
if err != nil {
	fmt.Println(err)

	return
}

fmt.Println(m.Current())

// The initial state is validated, which matters when it came out of a database column.
if _, err := fsm.NewEventMachine(orderGraph(), stateUnknown); err != nil {
	fmt.Println(err)
}
Output:
Draft
fsm: initial state Unknown: unknown state

func (*EventMachine[S, E]) CanFire

func (m *EventMachine[S, E]) CanFire(ctx context.Context, event E) error

CanFire reports whether firing event is permitted from the current state, returning nil when it is.

It checks that the edge exists and calls the guard registered on it, without moving the machine. It reports that the move is allowed, not that it will succeed: a blocking exit hook can still abort it.

Example:

if err := m.CanFire(ctx, EventShip); err != nil {
	// not allowed from here, and err says why
}

func (*EventMachine[S, E]) Current

func (m *EventMachine[S, E]) Current() S

Current returns the state the machine is in.

Example:

fmt.Println(m.Current())
// Draft

func (*EventMachine[S, E]) Fire

func (m *EventMachine[S, E]) Fire(ctx context.Context, event E) error

Fire moves the machine along the edge that event declares from the current state.

The stages run in order: the edge is resolved, the guard is called, the exit hook runs, the state changes, and the enter hook runs. A failed resolve, a refusing guard, and a blocking exit hook each leave the machine where it was. Errors from a reporting exit hook and from the enter hook are joined and returned after the state has changed.

Reading the returned error with errors.As gives a TransitionError whose Moved reports whether the state changed.

Calling Fire from inside a hook returns ErrReentrant and does nothing. The outer transition resolved its edge before the hook ran, and would overwrite a nested change when it resumes.

Example:

if err := m.Fire(ctx, EventSubmit); err != nil {
	var te *fsm.TransitionError[OrderState, OrderEvent]
	if errors.As(err, &te) && !te.Moved() {
		// nothing happened; safe to retry
	}
}
Example
ctx := context.Background()
m := fsm.MustEventMachine(orderGraph(), stateDraft)

if err := m.Fire(ctx, eventSubmit); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())

// No edge leaves Review on ship, so the machine stays put.
if err := m.Fire(ctx, eventShip); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())
Output:
Review
fsm: cannot fire ship from Review: invalid transition
Review

func (*EventMachine[S, E]) ForceState

func (m *EventMachine[S, E]) ForceState(state S) error

ForceState sets the current state directly, ignoring the graph.

No edge is required, no guard is called, and no hook runs, which sets aside every guarantee the rest of the package provides. It is meant for operational repair: support tooling, data migrations, and freeing an entity a bug stranded. Ordinary application code should declare the transition instead.

The one check is that the graph names the state, so a typo cannot invent one.

Example:

// in a repair script, not in a request handler
if err := m.ForceState(StateDraft); err != nil {
	// the graph never declares that state
}
Example
m := fsm.MustEventMachine(orderGraph(), stateShipped)

// ForceState ignores the graph entirely. It exists for operational repair, not for ordinary application code.
if err := m.ForceState(stateDraft); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())

// It still refuses a state the graph never declared.
if err := m.ForceState(stateUnknown); err != nil {
	fmt.Println(err)
}
Output:
Draft
fsm: force state Unknown: unknown state

func (*EventMachine[S, E]) Guard

func (m *EventMachine[S, E]) Guard(from S, event E, hook Hook[S, E]) *EventMachine[S, E]

Guard registers a predicate on the single edge (from, event).

The guard reports whether the move is allowed. It runs before anything with a side effect, so a refusal leaves the machine untouched.

It must not have side effects, because CanFire calls it without moving the machine. Guards key on the edge, so two events reaching the same target keep separate guards. Registering again replaces the previous guard.

Example:

m.Guard(StatePaid, EventShip, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	if order.Address == "" {
		return ErrNoAddress
	}
	return nil
})
Example
ctx := context.Background()
m := fsm.MustEventMachine(orderGraph(), stateDraft)

// A guard is a pure predicate, so CanFire may ask it without the move happening.
m.Guard(stateDraft, eventSubmit, func(context.Context, fsm.Transition[orderState, orderEvent]) error {
	return errors.New("order has no items")
})

fmt.Println(m.CanFire(ctx, eventSubmit))
fmt.Println(m.Current())
Output:
fsm: guard Draft -> Review (event submit): order has no items
Draft

func (*EventMachine[S, E]) Is

func (m *EventMachine[S, E]) Is(state S) bool

Is reports whether the machine is in state.

The comparison does not consult the graph, so any value of S may be passed.

Example:

if m.Is(StateShipped) {
	// ...
}

func (*EventMachine[S, E]) OnEnter

func (m *EventMachine[S, E]) OnEnter(state S, hook Hook[S, E]) *EventMachine[S, E]

OnEnter registers the hook that runs once the machine has entered state.

It runs after the state has changed and cannot stop the transition. Its error is reported to the caller, and Moved on that error returns true. Blocking is not offered here: undoing the transition would mean reverting a state change whose exit hook has already taken effect outside the machine.

A state has one enter hook. Registering again replaces it.

Example:

m.OnEnter(StateShipped, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	return notify(ctx, order)
})

func (*EventMachine[S, E]) OnExit

func (m *EventMachine[S, E]) OnExit(state S, hook Hook[S, E]) *EventMachine[S, E]

OnExit registers the hook that runs when the machine leaves state, reporting its error without stopping the move.

The state changes regardless, and the returned error reaches the caller alongside that fact. Use OnExitBlocking when the failure should prevent the move.

A state has one exit hook, shared with OnExitBlocking. Whichever is called last decides both the function and whether it blocks. Registering again replaces the previous hook.

Example:

m.OnExit(StatePaid, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	return pushMetrics(ctx, t.From, t.To)
})

func (*EventMachine[S, E]) OnExitBlocking

func (m *EventMachine[S, E]) OnExitBlocking(state S, hook Hook[S, E]) *EventMachine[S, E]

OnExitBlocking registers the hook that runs when the machine leaves state, aborting the transition if it fails.

It runs before the state changes, so an abort leaves the machine where it was and the enter hook does not run. Work that should prevent a move when it fails belongs here: the transition does not happen, leaving nothing to undo.

A state has one exit hook, shared with OnExit. See OnExit for the replacement rule.

Example:

m.OnExitBlocking(StatePaid, func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	return releaseHold(ctx, order)
})
Example
ctx := context.Background()
m := fsm.MustEventMachine(orderGraph(), stateDraft)

// Work that can fail and whose failure should prevent the move belongs here, so the transition never commits and
// there is nothing to undo.
m.OnExitBlocking(stateDraft, func(context.Context, fsm.Transition[orderState, orderEvent]) error {
	return errors.New("hold not released")
})

if err := m.Fire(ctx, eventSubmit); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())
Output:
fsm: exit Draft -> Review (event submit): hold not released
Draft

func (*EventMachine[S, E]) String

func (m *EventMachine[S, E]) String() string

String returns the current state name, so EventMachine satisfies fmt.Stringer.

Example:

fmt.Printf("%s", m)
// Draft

type Graph

type Graph[S ~string] struct {
	// contains filtered or unexported fields
}

Graph declares the states of a machine and the transitions between them, without naming the transitions.

An edge is identified by the pair (from, to), and a machine moves by naming its destination. Use EventGraph when one action must lead to different targets depending on where the machine is, which naming the destination cannot express.

The zero value is an empty graph that allows no transition. Build one with NewGraph. A built graph holds its own copy of the edges.

Example:

graph := fsm.NewGraph[OrderState]().
	To(StateDraft, StateReview, StateCanceled).
	MustBuild()

func (Graph[S]) Mermaid

func (g Graph[S]) Mermaid() string

Mermaid renders the graph as a Mermaid state diagram, with unlabeled edges.

Example:

fmt.Println(statusGraph.Mermaid())
// stateDiagram-v2
//     Draft --> Canceled
//     Draft --> Review

func (Graph[S]) String

func (g Graph[S]) String() string

String renders the graph as one edge per line, so Graph satisfies fmt.Stringer.

No event names appear, since this surface has none. Edges are sorted by source and then by target, so the output is stable across calls. An empty graph renders as the empty string.

Example:

fmt.Println(statusGraph)
// Draft -> Canceled
// Draft -> Review
// Review -> Paid
Example
graph := fsm.NewGraph[orderState]().
	To(stateDraft, stateReview, stateCanceled).
	To(stateReview, statePaid).
	MustBuild()

fmt.Println(graph)
Output:
Draft -> Canceled
Draft -> Review
Review -> Paid

type GraphBuilder

type GraphBuilder[S ~string] struct {
	// contains filtered or unexported fields
}

GraphBuilder accumulates edges and the conflicts found while declaring them.

Every method returns the builder, so declarations chain. Conflicts are collected and surfaced together by Build or MustBuild.

Example:

builder := fsm.NewGraph[OrderState]()
builder = builder.To(StateDraft, StateReview)
graph, err := builder.Build()

func NewGraph

func NewGraph[S ~string]() *GraphBuilder[S]

NewGraph returns an empty builder for a graph whose states are of type S.

Example:

type OrderState string

builder := fsm.NewGraph[OrderState]()
Example
ctx := context.Background()

graph := fsm.NewGraph[orderState]().
	To(stateDraft, stateReview, stateCanceled).
	To(stateReview, statePaid).
	MustBuild()

m := fsm.MustNew(graph, stateDraft)

if err := m.TransitionTo(ctx, stateReview); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())

// No edge leads from Review to Shipped.
if err := m.TransitionTo(ctx, stateShipped); err != nil {
	fmt.Println(err)
}
Output:
Review
fsm: cannot transition Review -> Shipped: invalid transition

func (*GraphBuilder[S]) Build

func (b *GraphBuilder[S]) Build() (Graph[S], error)

Build returns the declared graph, or every conflict found while declaring it.

Conflicts are joined with errors.Join. Use Build when the graph is assembled conditionally; use MustBuild for a package-level variable.

Example:

graph, err := builder.Build()
if err != nil {
	// duplicate edge Draft -> Review
}

func (*GraphBuilder[S]) MustBuild

func (b *GraphBuilder[S]) MustBuild() Graph[S]

MustBuild returns the declared graph and panics if any conflict was found.

Example:

var orderGraph = fsm.NewGraph[OrderState]().
	To(StateDraft, StateReview).
	MustBuild()

func (*GraphBuilder[S]) To

func (b *GraphBuilder[S]) To(from S, to ...S) *GraphBuilder[S]

To declares that the machine may move from from to each of the given targets.

It is variadic so that one call declares every edge leaving a state, reading as a row of a transition table. Declaring the same pair (from, to) more than once is a conflict, reported by Build or MustBuild. Calling To with no targets declares nothing.

Example:

builder.To(StateDraft, StateReview, StateCanceled)

type Hook

type Hook[S ~string, E ~string] func(context.Context, Transition[S, E]) error

Hook is a guard or a lifecycle callback.

A guard must not have side effects, because CanFire calls it without moving the machine. Exit and enter hooks may do real work, including I/O, and run only for a transition that is taking place.

Example:

func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	return notify(ctx, t.To)
}

type Machine

type Machine[S ~string] struct {
	// contains filtered or unexported fields
}

Machine holds one current state and applies only the transitions its graph declares, naming them by destination.

It is built on the same engine as EventMachine, with each edge's event name bound to its target state. That binding does not appear in its types, methods, error messages, or hook arguments.

Like EventMachine it holds no lock and is not safe for concurrent use. Build one with New or MustNew.

Example:

m := fsm.MustNew(orderGraph, StateDraft)
err := m.TransitionTo(ctx, StateReview)

func MustNew

func MustNew[S ~string](graph Graph[S], initial S) *Machine[S]

MustNew returns a machine positioned at initial and panics if the graph never names that state.

Use it where the initial state is a constant. Prefer New when the state came from storage or a request.

Example:

m := fsm.MustNew(orderGraph, StateDraft)

func New

func New[S ~string](graph Graph[S], initial S) (*Machine[S], error)

New returns a machine positioned at initial, or ErrUnknownState if the graph never names that state.

As with NewEventMachine, the check matters because construction is where a state read from storage enters the program.

Example:

m, err := fsm.New(orderGraph, order.Status)
if err != nil {
	// the stored status is not in the graph
}

func (*Machine[S]) CanTransitionTo

func (m *Machine[S]) CanTransitionTo(ctx context.Context, to S) error

CanTransitionTo reports whether moving to the given state is permitted, returning nil when it is.

It checks that the edge exists and calls the guard registered on it, without moving the machine. It reports that the move is allowed, not that it will succeed: a blocking exit hook can still abort it.

Example:

if err := m.CanTransitionTo(ctx, StateShipped); err != nil {
	// not allowed from here, and err says why
}

func (*Machine[S]) Current

func (m *Machine[S]) Current() S

Current returns the state the machine is in.

Example:

fmt.Println(m.Current())
// Draft

func (*Machine[S]) ForceState

func (m *Machine[S]) ForceState(state S) error

ForceState sets the current state directly, ignoring the graph.

No edge is required, no guard is called, and no hook runs. As on the labeled surface it is meant for operational repair, and its one check is that the graph names the state.

Example:

// in a repair script, not in a request handler
if err := m.ForceState(StateDraft); err != nil {
	// the graph never declares that state
}

func (*Machine[S]) Guard

func (m *Machine[S]) Guard(from, to S, hook StateHook[S]) *Machine[S]

Guard registers a predicate on the single edge from -> to.

It must not have side effects, because CanTransitionTo calls it without moving the machine. Registering again replaces the previous guard.

Example:

m.Guard(StateReview, StatePaid, func(ctx context.Context, c fsm.Change[OrderState]) error {
	if order.Total == 0 {
		return ErrNothingToPay
	}
	return nil
})

func (*Machine[S]) Is

func (m *Machine[S]) Is(state S) bool

Is reports whether the machine is in state.

Example:

if m.Is(StateShipped) {
	// ...
}

func (*Machine[S]) OnEnter

func (m *Machine[S]) OnEnter(state S, hook StateHook[S]) *Machine[S]

OnEnter registers the hook that runs once the machine has entered state.

It runs after the state has changed and cannot stop the transition. Its error is reported to the caller.

Example:

m.OnEnter(StateShipped, func(ctx context.Context, c fsm.Change[OrderState]) error {
	return notify(ctx, order)
})
Example
ctx := context.Background()
m := fsm.MustNew(statusGraph(), stateDraft)

m.OnEnter(stateReview, func(_ context.Context, c fsm.Change[orderState]) error {
	fmt.Printf("moved %s -> %s\n", c.From, c.To)

	return nil
})

if err := m.TransitionTo(ctx, stateReview); err != nil {
	fmt.Println(err)
}
Output:
moved Draft -> Review

func (*Machine[S]) OnExit

func (m *Machine[S]) OnExit(state S, hook StateHook[S]) *Machine[S]

OnExit registers the hook that runs when the machine leaves state, reporting its error without stopping the move.

A state has one exit hook, shared with OnExitBlocking. Whichever is called last decides both the function and whether it blocks. Registering again replaces the previous hook.

Example:

m.OnExit(StatePaid, func(ctx context.Context, c fsm.Change[OrderState]) error {
	return pushMetrics(ctx, c.From, c.To)
})

func (*Machine[S]) OnExitBlocking

func (m *Machine[S]) OnExitBlocking(state S, hook StateHook[S]) *Machine[S]

OnExitBlocking registers the hook that runs when the machine leaves state, aborting the transition if it fails.

It runs before the state changes, so an abort leaves the machine where it was and the enter hook does not run.

Example:

m.OnExitBlocking(StatePaid, func(ctx context.Context, c fsm.Change[OrderState]) error {
	return releaseHold(ctx, order)
})

func (*Machine[S]) String

func (m *Machine[S]) String() string

String returns the current state name, so Machine satisfies fmt.Stringer.

Example:

fmt.Printf("%s", m)
// Draft

func (*Machine[S]) TransitionTo

func (m *Machine[S]) TransitionTo(ctx context.Context, to S) error

TransitionTo moves the machine to the given state, if the graph declares an edge leading there.

The stages run in the same order as on the labeled surface: the edge is resolved, the guard is called, the exit hook runs, the state changes, and the enter hook runs. Calling it from inside a hook returns ErrReentrant.

Example:

if err := m.TransitionTo(ctx, StateReview); err != nil {
	// fsm: cannot transition Draft -> Review: invalid transition
}
Example
ctx := context.Background()
m := fsm.MustNew(statusGraph(), stateDraft)

if err := m.TransitionTo(ctx, stateReview); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())

// The graph declares no edge from Review back to Draft.
if err := m.TransitionTo(ctx, stateDraft); err != nil {
	fmt.Println(err)
}

fmt.Println(m.Current())
Output:
Review
fsm: cannot transition Review -> Draft: invalid transition
Review

type Phase

type Phase uint8

Phase identifies the stage of a transition at which it failed.

PhaseResolve and PhaseGuard mean nothing moved and nothing ran. PhaseExit means nothing moved, though an exit hook may already have had an effect. PhaseEnter means the state changed and a hook failed afterwards.

The zero value is PhaseResolve. Changing the state is not a phase: it is one assignment that cannot fail. Use TransitionError.Moved rather than the phase to tell whether the state changed, since a reporting exit hook fails at PhaseExit and the machine still moves.

The numeric values may shift if a stage is added. Store and label phases with String, not with uint8(phase).

Example:

switch te.Phase {
case fsm.PhaseGuard:
	// a rule refused; nothing happened
case fsm.PhaseEnter:
	// the move succeeded; do not retry it
}
Example
package main

import (
	"fmt"

	"github.com/behzadsh/fsm"
)

func main() {
	// Phase reports how far a failed transition got.
	fmt.Println(fsm.PhaseResolve, fsm.PhaseGuard, fsm.PhaseExit, fsm.PhaseEnter)

}
Output:
resolve guard exit enter
const (
	// PhaseResolve is the lookup of the edge for the current state and the given event.
	PhaseResolve Phase = iota

	// PhaseGuard is the guard registered on the resolved edge.
	PhaseGuard

	// PhaseExit is the hook leaving the source state. It runs before the state changes.
	PhaseExit

	// PhaseEnter is the hook entering the target state. It runs after the state has changed.
	PhaseEnter
)

The stages of a transition, in the order they run.

func (Phase) String

func (p Phase) String() string

String returns the phase name in lower case, suitable for an error message or a metrics label.

Example:

fmt.Println(fsm.PhaseGuard)
// guard

type StateHook

type StateHook[S ~string] func(context.Context, Change[S]) error

StateHook is a guard or a lifecycle callback on the simple surface.

The rules match Hook: a guard must not have side effects, because CanTransitionTo calls it without moving the machine, while exit and enter hooks may do real work and run only for a transition that is taking place.

Example:

func(ctx context.Context, c fsm.Change[OrderState]) error {
	return notify(ctx, c.To)
}

type Transition

type Transition[S ~string, E ~string] struct {
	// From is the state the machine is leaving.
	From S

	// To is the state the machine is entering.
	To S

	// Event is the event that resolved this edge.
	Event E
}

Transition describes one move: where it started, where it leads, and the event that caused it.

It is the value passed to guards and hooks, so they see the whole move rather than only the state they were registered against.

Example:

func(ctx context.Context, t fsm.Transition[OrderState, OrderEvent]) error {
	log.Info("moving", "from", t.From, "to", t.To, "via", t.Event)
	return nil
}

type TransitionError

type TransitionError[S ~string, E ~string] struct {
	// From is the state the machine was in when the transition was attempted.
	From S

	// To is the target of the resolved edge, or the zero value when resolve failed.
	To S

	// Event is the event that was fired.
	Event E

	// Phase is the stage at which the transition failed.
	Phase Phase

	// Committed records whether the state had already changed when this error was produced. Moved reports it. It
	// cannot be derived from Phase alone, because a reporting exit hook fails at PhaseExit without stopping the
	// transition.
	Committed bool

	// Err is the underlying cause, and is what Unwrap returns.
	Err error
}

TransitionError describes a transition that did not complete, and how far it got.

To is the zero value when resolve failed, since no edge was found and no destination is known. In every other phase both ends are named.

Retrieve it with errors.As. Read Moved to tell whether the state changed.

Example:

var te *fsm.TransitionError[OrderState, OrderEvent]
if errors.As(err, &te) && te.Phase == fsm.PhaseEnter {
	// the order did move; only the notification failed
}
Example
err := error(&fsm.TransitionError[orderState, orderEvent]{
	From:  statePaid,
	To:    stateShipped,
	Event: eventShip,
	Phase: fsm.PhaseEnter,
	Err:   errors.New("notify failed"),
})

fmt.Println(err)

// PhaseEnter means the machine did move; only the follow-up failed, so the caller should log rather than retry.
var te *fsm.TransitionError[orderState, orderEvent]
if errors.As(err, &te) {
	fmt.Println(te.Phase == fsm.PhaseEnter)
}
Output:
fsm: enter Paid -> Shipped (event ship): notify failed
true

func (*TransitionError[S, E]) Error

func (e *TransitionError[S, E]) Error() string

Error renders the failure, naming a destination only when resolve found one.

Example:

// fsm: cannot fire pay from Draft: invalid transition
// fsm: exit Paid -> Shipped (event ship): hold not released

func (*TransitionError[S, E]) Moved

func (e *TransitionError[S, E]) Moved() bool

Moved reports whether the state changed before the failure.

False means nothing moved and the call can be retried. True means the transition took effect and a reported hook failed afterwards, so retrying would repeat work that already happened.

This is not the same as which phase failed. A reporting exit hook fails at PhaseExit without stopping the transition, so the state changes and Moved returns true.

Example:

var te *fsm.TransitionError[OrderState, OrderEvent]
if errors.As(err, &te) && te.Moved() {
	// the order moved; retrying would repeat it
}

func (*TransitionError[S, E]) Unwrap

func (e *TransitionError[S, E]) Unwrap() error

Unwrap returns the underlying cause, so errors.Is reaches this package's sentinels and any error a guard or hook returned.

Example:

errors.Is(err, fsm.ErrInvalidTransition)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL