State Pattern in Go: Interface-Based States

The State pattern lets an object alter its behavior when its internal state changes. Instead of littering your code with conditionals that check state before every operation, you encapsulate...

Key Insights

  • Go’s implicit interface satisfaction makes the State pattern remarkably clean—states don’t need to declare what they implement, they just implement it
  • Thread-safe state machines require careful mutex placement; protect the state reference, not the state behavior itself
  • The State pattern shines when you have 4+ states with complex transition rules; for simpler cases, a switch statement is often more maintainable

Introduction to the State Pattern

The State pattern lets an object alter its behavior when its internal state changes. Instead of littering your code with conditionals that check state before every operation, you encapsulate state-specific behavior in separate types. The object appears to change its class.

Go’s interface system makes this pattern particularly elegant. Unlike languages requiring explicit interface declarations, Go types satisfy interfaces implicitly. You define what behaviors a state must have, implement those behaviors in concrete types, and Go handles the rest.

Consider a document workflow system. A document moves through states: draft, review, published, and archived. Each state has different rules about what operations are valid. In draft, you can edit freely. In review, only reviewers can approve or reject. Once published, edits create a new draft version.

Here’s the mess you get without the State pattern:

func (d *Document) Submit() error {
    switch d.status {
    case "draft":
        d.status = "review"
        return nil
    case "review":
        return errors.New("already in review")
    case "published":
        return errors.New("cannot submit published document")
    case "archived":
        return errors.New("cannot submit archived document")
    default:
        return errors.New("unknown state")
    }
}

func (d *Document) Approve() error {
    switch d.status {
    case "draft":
        return errors.New("must submit before approval")
    case "review":
        d.status = "published"
        return nil
    // ... more cases
    }
}

Every method needs a switch statement. Add a new state, and you’re hunting through every method to add a case. The State pattern eliminates this coupling.

Defining the State Interface

Start with the smallest interface that captures state behavior. Go developers often over-engineer interfaces. Resist that urge. You need methods for the operations your domain requires, a way to identify the state, and optionally a method to get valid transitions.

type State interface {
    // Handle processes an action in the current state.
    // Returns the next state and any error.
    Handle(ctx context.Context, action Action) (State, error)
    
    // Name returns a string identifier for the state.
    Name() string
    
    // AllowedActions returns actions valid in this state.
    AllowedActions() []Action
}

type Action string

const (
    ActionEdit    Action = "edit"
    ActionSubmit  Action = "submit"
    ActionApprove Action = "approve"
    ActionReject  Action = "reject"
    ActionArchive Action = "archive"
)

Notice Handle returns the next state rather than mutating anything. This functional approach simplifies testing and makes state transitions explicit. The context parameter allows cancellation and deadline propagation—essential for real systems where state transitions might involve I/O.

Some implementations pass the context object (the state machine itself) to state methods. I avoid this pattern. It creates circular dependencies and makes states harder to test. States should be pure: given an action, return the next state or an error.

Implementing Concrete States

Each concrete state is a struct implementing the State interface. States can be stateless singletons or carry state-specific data.

type DraftState struct{}

func (s *DraftState) Name() string {
    return "draft"
}

func (s *DraftState) AllowedActions() []Action {
    return []Action{ActionEdit, ActionSubmit}
}

func (s *DraftState) Handle(ctx context.Context, action Action) (State, error) {
    switch action {
    case ActionEdit:
        return s, nil // Stay in draft
    case ActionSubmit:
        return &ReviewState{}, nil
    default:
        return nil, fmt.Errorf("action %q not allowed in draft state", action)
    }
}

type ReviewState struct {
    submittedAt time.Time
    reviewerID  string
}

func (s *ReviewState) Name() string {
    return "review"
}

func (s *ReviewState) AllowedActions() []Action {
    return []Action{ActionApprove, ActionReject}
}

func (s *ReviewState) Handle(ctx context.Context, action Action) (State, error) {
    switch action {
    case ActionApprove:
        return &PublishedState{publishedAt: time.Now()}, nil
    case ActionReject:
        return &DraftState{}, nil
    default:
        return nil, fmt.Errorf("action %q not allowed in review state", action)
    }
}

type PublishedState struct {
    publishedAt time.Time
}

func (s *PublishedState) Name() string {
    return "published"
}

func (s *PublishedState) AllowedActions() []Action {
    return []Action{ActionArchive}
}

func (s *PublishedState) Handle(ctx context.Context, action Action) (State, error) {
    switch action {
    case ActionArchive:
        return &ArchivedState{archivedAt: time.Now()}, nil
    default:
        return nil, fmt.Errorf("action %q not allowed in published state", action)
    }
}

type ArchivedState struct {
    archivedAt time.Time
}

func (s *ArchivedState) Name() string {
    return "archived"
}

func (s *ArchivedState) AllowedActions() []Action {
    return []Action{} // Terminal state
}

func (s *ArchivedState) Handle(ctx context.Context, action Action) (State, error) {
    return nil, errors.New("archived documents cannot be modified")
}

Each state encapsulates its own transition logic. Adding a new state means creating a new type and updating only the states that can transition to it. The explosion of switch statements is gone.

Building the Context (State Machine)

The context holds the current state and delegates operations to it. In concurrent systems, you need mutex protection around state changes.

type Document struct {
    ID      string
    Title   string
    Content string
    
    mu    sync.RWMutex
    state State
}

func NewDocument(id, title string) *Document {
    return &Document{
        ID:    id,
        Title: title,
        state: &DraftState{},
    }
}

func (d *Document) State() State {
    d.mu.RLock()
    defer d.mu.RUnlock()
    return d.state
}

func (d *Document) Process(ctx context.Context, action Action) error {
    d.mu.Lock()
    defer d.mu.Unlock()
    
    nextState, err := d.state.Handle(ctx, action)
    if err != nil {
        return fmt.Errorf("failed to process %q in state %q: %w", 
            action, d.state.Name(), err)
    }
    
    d.state = nextState
    return nil
}

func (d *Document) CanPerform(action Action) bool {
    d.mu.RLock()
    defer d.mu.RUnlock()
    
    for _, allowed := range d.state.AllowedActions() {
        if allowed == action {
            return true
        }
    }
    return false
}

The mutex protects the state reference, not the state’s behavior. State implementations should be safe for concurrent reads. If a state transition involves external I/O (database writes, API calls), consider using a transaction pattern or optimistic locking instead of holding the mutex during I/O.

State Transitions and Guards

Real systems need transition guards—conditions beyond “is this action allowed in this state?” Maybe only certain users can approve, or documents need minimum content length before submission.

type TransitionGuard func(ctx context.Context, doc *Document) error

type GuardedState struct {
    State
    guards map[Action][]TransitionGuard
}

func (s *GuardedState) Handle(ctx context.Context, action Action) (State, error) {
    if guards, ok := s.guards[action]; ok {
        for _, guard := range guards {
            // Note: In real code, you'd pass the document differently
            if err := guard(ctx, nil); err != nil {
                return nil, fmt.Errorf("guard failed: %w", err)
            }
        }
    }
    return s.State.Handle(ctx, action)
}

// Example guard
func MinContentLength(min int) TransitionGuard {
    return func(ctx context.Context, doc *Document) error {
        if len(doc.Content) < min {
            return fmt.Errorf("content must be at least %d characters", min)
        }
        return nil
    }
}

For complex guard logic, consider a separate validator that runs before state transitions rather than embedding guards in states. This keeps states focused on transition logic.

Testing State Machines

Table-driven tests work beautifully for state machines. Test each state’s transitions independently, then test full sequences.

func TestDraftStateTransitions(t *testing.T) {
    tests := []struct {
        name       string
        action     Action
        wantState  string
        wantErr    bool
    }{
        {"edit stays in draft", ActionEdit, "draft", false},
        {"submit moves to review", ActionSubmit, "review", false},
        {"approve not allowed", ActionApprove, "", true},
        {"reject not allowed", ActionReject, "", true},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            state := &DraftState{}
            next, err := state.Handle(context.Background(), tt.action)
            
            if tt.wantErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
                return
            }
            
            if err != nil {
                t.Fatalf("unexpected error: %v", err)
            }
            
            if next.Name() != tt.wantState {
                t.Errorf("got state %q, want %q", next.Name(), tt.wantState)
            }
        })
    }
}

func TestDocumentWorkflow(t *testing.T) {
    doc := NewDocument("doc-1", "Test Document")
    
    workflow := []struct {
        action    Action
        wantState string
    }{
        {ActionEdit, "draft"},
        {ActionSubmit, "review"},
        {ActionApprove, "published"},
        {ActionArchive, "archived"},
    }
    
    for _, step := range workflow {
        if err := doc.Process(context.Background(), step.action); err != nil {
            t.Fatalf("action %q failed: %v", step.action, err)
        }
        
        if doc.State().Name() != step.wantState {
            t.Errorf("after %q: got state %q, want %q", 
                step.action, doc.State().Name(), step.wantState)
        }
    }
}

Real-World Considerations

Use the State pattern when you have four or more states with non-trivial transition logic. For simple cases—a boolean flag or three states with obvious transitions—a switch statement is clearer and faster.

The pattern combines well with event sourcing. Instead of storing current state, store the sequence of actions. Replay them to reconstruct state. This gives you audit logs for free and enables temporal queries.

For high-throughput systems, consider these optimizations: use stateless singleton states (no allocations on transitions), replace interface dispatch with a state enum and switch statement if profiling shows it matters, and batch state transitions when possible.

The State pattern transforms sprawling conditionals into focused, testable types. Go’s interfaces make the implementation clean. Use it when complexity warrants it, and your future self will thank you when adding that sixth state.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.