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.