Map

Many-Step Sequences in Go

Wiki summarygolangpatternstesting โ†ณ show in map Markdown
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
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 nil to 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.