Workflow models the lifecycle of application objects as places and transitions. It supports both multi-place workflows and strict single-state machines, with weighted arcs, transition guards, YAML configuration, events, metadata, registries, diagnostics, and deterministic diagram exporters.
The package keeps definitions immutable and application state in ordinary Go values. Subjects can expose small state methods, use accessor functions, or provide a custom marking store. Event delivery is delegated to event-dispatcher-go.
Workflow requires Go 1.26 or newer.
go get github.com/lemric/workflow-goImport the package with the workflow name:
import "github.com/lemric/workflow-go"The following state machine moves an article from draft to reviewed and then to published:
package main
import (
"fmt"
"log"
"github.com/lemric/workflow-go"
)
type Article struct {
state string
}
func (a *Article) WorkflowState() string {
return a.state
}
func (a *Article) SetWorkflowState(state string, _ map[string]any) {
a.state = state
}
func main() {
draft, err := workflow.NewArc("draft", 1)
if err != nil {
log.Fatal(err)
}
reviewed, err := workflow.NewArc("reviewed", 1)
if err != nil {
log.Fatal(err)
}
published, err := workflow.NewArc("published", 1)
if err != nil {
log.Fatal(err)
}
submit, err := workflow.NewTransition("submit", []workflow.Arc{draft}, []workflow.Arc{reviewed})
if err != nil {
log.Fatal(err)
}
publish, err := workflow.NewTransition("publish", []workflow.Arc{reviewed}, []workflow.Arc{published})
if err != nil {
log.Fatal(err)
}
definition, err := workflow.NewDefinition(
[]string{"draft", "reviewed", "published"},
[]*workflow.Transition{submit, publish},
workflow.WithInitialPlaces("draft"),
)
if err != nil {
log.Fatal(err)
}
machine, err := workflow.NewStateMachine(definition, workflow.WithName("article"))
if err != nil {
log.Fatal(err)
}
article := &Article{state: "draft"}
allowed, err := machine.Can(article, "submit")
if err != nil {
log.Fatal(err)
}
fmt.Println(allowed)
marking, err := machine.Apply(article, "submit", map[string]any{"actor": "editor"})
if err != nil {
log.Fatal(err)
}
fmt.Println(article.state, marking.Has("reviewed"))
// Output:
// true
// reviewed true
}NewStateMachine uses the subject's WorkflowState and SetWorkflowState methods by default. Calling GetMarking initializes an empty subject from the definition's initial place.
Applications may load the same model from a strict YAML document. Portable support names are explicitly bound to Go strategies; method marking stores can read a configured property through Go accessors or an exported field:
registry := workflow.NewRegistry()
bindings := workflow.NewConfigurationBindings()
if err := bindings.RegisterSupport(
"order",
workflow.TypeSupportStrategy[*Order](),
); err != nil {
log.Fatal(err)
}
loader := workflow.NewYAMLLoader(registry, bindings)
if err := loader.Load("config/workflows.yaml"); err != nil {
log.Fatal(err)
}The loader uses lemric/yaml-go exclusively for strict, ordered YAML decoding and expression-language-go for configured guards. It supports weighted arcs and both top-level and nested framework.workflows forms. See YAML configuration for the full schema and a weighted-workflow example.
Runtime inputs and retained package state have safe defaults designed for a 100 MiB service budget. YAML input is read through a bounded reader (1 MiB by default), nesting is limited, registries reject growth past their configured capacity, model constructors reject oversized graphs and markings before copying them, reflection/event-name caches retain a fixed amount, and TraceableWorkflow keeps only its newest 128 calls.
Use DefaultResourceLimits, NewRegistryWithLimits, and NewYAMLLoaderWithLimits to lower limits for a deployment. Limit failures unwrap to ErrResourceLimit; callers should treat them as backpressure/configuration errors. Go's GOMEMLIMIT=100MiB is also recommended when the complete process must target 100 MiB. It is a soft runtime limit, so no library can guarantee survival when caller-owned objects, custom listeners/stores, native allocations, or the rest of the application already exhaust the process budget.
The package revolves around five concepts:
- A place represents a condition or state.
- A transition consumes tokens from input places and creates tokens in output places.
- A definition is the immutable graph of places and transitions.
- A marking records the places currently occupied by a subject.
- A workflow evaluates and applies transitions while a marking store reads and writes subject state.
Use NewWorkflow when a subject may occupy several places or use weighted tokens. Use NewStateMachine when exactly one place may be active and every arc has weight one.
| Capability | Main API |
|---|---|
| Immutable graph definition | NewDefinition, DefinitionBuilder |
| Multi-place workflow | NewWorkflow |
| Single-state lifecycle | NewStateMachine |
| Weighted input and output arcs | Arc, NewTransition |
| Transition checks and execution | Can, Apply, EnabledTransitions |
| Detailed rejection reasons | TransitionBlockers, NotEnabledTransitionError |
| Custom state persistence | MarkingStore, accessor stores |
| Guard rules and lifecycle events | GuardEvent, WithDispatcher |
| Workflow selection | Registry, SupportStrategy |
| Strict YAML configuration | YAMLLoader, ConfigurationBindings |
| Workflow and node metadata | MetadataStore |
| Diagram export | Graphviz, Mermaid, PlantUML dumpers |
| Runtime inspection | TraceableWorkflow, DiagnosticsCollector |
- Documentation index
- Installation
- Getting started
- Definitions, places, transitions, and markings
- Using workflows and state machines
- Guards and events
- Marking stores and registry
- YAML configuration
- Metadata, diagrams, and diagnostics
- Performance and concurrency
- API reference
Package errors work with errors.Is. Transition failures also expose structured details through errors.As:
marking, err := machine.Apply(article, "publish", nil)
if err != nil {
var blocked *workflow.NotEnabledTransitionError
if errors.As(err, &blocked) {
for _, blocker := range blocked.Blockers.All() {
log.Printf("%s: %s", blocker.Code, blocker.Message)
}
}
}
_ = markingAn undefined transition unwraps to ErrUndefinedTransition. A defined but unavailable transition unwraps to ErrNotEnabledTransition and carries its blocker list, subject, workflow, and copied context.
go test ./...
go test -race ./...
go vet ./...The benchmark suite models 1000 configured workflows and a pool of one million subjects:
go test -run '^$' \
-bench '^Benchmark(Decode|Load)1000WorkflowsFromYAML' \
-benchtime=1x -benchmem -count=5 ./...
go test -run '^$' -bench '^BenchmarkConfigure1000Workflows$' \
-benchtime=1000x -benchmem ./...
go test -run '^$' \
-bench '^Benchmark(RegistryLookup|CanMillion|ApplyMillion)' \
-benchtime=1000000x -benchmem ./...For the YAML benchmarks, one operation is one complete 1000-workflow deployment load. For the runtime benchmarks, one operation is one configuration, lookup, check, or apply call. The production-sized benchtime values control how many individual runtime operations are executed, so their allocs/op remains a per-action metric.