Many-Step Sequences in Go
- title
- Many-Step Sequences in Go
- type
- summary
- summary
- State machine pattern for multi-step tasks where each step returns the next, enabling independent testing
- tags
- golang, patterns, testing
- sources
- many-step-sequences-in-go
- created
- 2026-04-16
- updated
- 2026-04-16
Chris Lesiw describes a pattern for organizing multi-step sequences in Go โ the kind you get when converting shell scripts or writing deployment automation. The core idea: each step returns the next step to execute, forming a state machine that's easy to test without cascading failures.
The problem
Shell-to-Go conversions often produce mega-functions:
func deploy(ctx context.Context) error {
if err := build(ctx); err != nil { return err }
if err := test(ctx); err != nil { return err }
if err := upload(ctx); err != nil { return err }
if err := notify(ctx); err != nil { return err }
return nil
}
Testing upload requires build and test to succeed first. A bug in build cascades into every downstream test. You can't isolate steps.
The pattern
Inspired by Rob Pike's 2011 lexer talk, define steps as functions that return the next step:
type Func[T any] func(context.Context) (Func[T], error)
The driver is trivial:
for f != nil {
f, err = f(ctx)
}
Each step does its work, then returns either the next step or nil to terminate.
Method values for state
Instead of threading state through parameters, use method values:
type deployer struct {
artifact string
version string
}
func (d *deployer) build(ctx context.Context) (step.Func[deployer], error) {
d.artifact = "app.tar.gz"
return d.test, nil
}
func (d *deployer) test(ctx context.Context) (step.Func[deployer], error) {
// use d.artifact
return d.upload, nil
}
The method value d.build captures d at the call site. Steps access shared state through the receiver without polluting signatures.
Generic parameter as namespace
The T in Func[T] isn't used at runtime โ it's a compile-time namespace. Func[deployer] can only return Func[deployer], not Func[installer]. This prevents accidentally chaining steps from different sequences.
Testing without cascades
The library provides runtime function comparison:
func Equal[T any](a, b Func[T]) bool {
return fullName(a) == fullName(b)
}
Test that build returns test, that test returns upload. A bug in build doesn't cascade into upload tests โ you're testing transitions, not execution chains.
func TestBuildReturnsTest(t *testing.T) {
d := &deployer{}
next, err := d.build(ctx)
if !step.Equal(next, d.test) {
t.Error("expected test step")
}
}
Handler for progress
The library includes a Handler interface for observing step completion:
type Handler interface {
Handle(Info)
}
Built-in Log handler shows progress:
- โ successful steps
- โ failed steps
- โ non-fatal errors
Orthogonal concerns
The two return values are independent:
- Returned function controls flow (next step or
nilto stop) - Returned error goes to handlers for reporting
A step can return an error and still continue (return d.next, err), or stop without error (return nil, nil).
The library
Published as lesiw.io/step. Useful for deployment scripts, installers, CI pipelines โ anywhere you have sequential operations that deserve individual testing.
See also go-analysis-framework for another Go pattern involving chained operations, behavior-tree for a different approach to task sequencing.