The Problem
Objects with life cycles often accumulate large switch statements over status fields. An order may allow payment only in one state, shipping in another, and cancellation in several. As the workflow grows, those branches become harder to test and easier to break.
The Solution
State moves behavior and transition rules into dedicated state objects. In Go, the context stores an interface for the current state and delegates stateful operations to it. Each concrete state decides whether an action is allowed and what the next state should be.
Structure
The State Interface
OrderState declares the operations that vary by lifecycle stage: Pay, Ship, Cancel. Each concrete state implements what is legal and what transition comes next — no switch statements anywhere.
flowchart TD Client["Client"] Order["Order context"] StateIface["OrderState interface"] Pending["PendingState"] Paid["PaidState"] Shipped["ShippedState"] Cancelled["CancelledState"] Client -->|"Pay() / Ship() / Cancel()"| Order Order -->|"delegates to"| StateIface Pending -.->|"implements"| StateIface Paid -.->|"implements"| StateIface Shipped -.->|"implements"| StateIface Cancelled -.->|"implements"| StateIface Pending -->|"Pay()"| Paid Paid -->|"Ship()"| Shipped Pending -->|"Cancel()"| Cancelled Paid -->|"Cancel()"| Cancelled
- Context:
Orderstores the current state. - State interface: Declares the operations that vary by state.
- Concrete states: Pending, paid, shipped, and cancelled states enforce different rules.
- Client: Calls operations on the order without branching on status values.
Implementation
This example models a basic order workflow. Each state object owns the legal transitions, and the order delegates Pay, Ship, and Cancel to whichever state is active.
package main
type Order struct {
ID string
state OrderState
}
func NewOrder(id string) *Order {
return &Order{ID: id, state: PendingState{}}
}
func (o *Order) setState(state OrderState) {
o.state = state
}
func (o *Order) Status() string {
return o.state.Name()
}
func (o *Order) Pay() error {
return o.state.Pay(o)
}
func (o *Order) Ship() error {
return o.state.Ship(o)
}
func (o *Order) Cancel() error {
return o.state.Cancel(o)
} Real-World Analogy
Think of a traffic light. The same control box behaves differently depending on whether the current state is green, yellow, or red, and the legal next transition depends on that current state.
Pros and Cons
| Pros | Cons |
|---|---|
| Localizes lifecycle-specific rules close to each state. | Introduces more types and indirection than a simple switch. |
| Removes large status-based conditionals from the context object. | Transition logic can become scattered across several state objects. |
| Makes invalid transitions easier to model explicitly. | Not worth it for small workflows with only a few stable branches. |
Best Practices
- Use State when behavior truly changes by lifecycle stage, not just because a label exists.
- Keep state interfaces focused on the operations that vary.
- Let concrete states own transitions so invalid moves stay localized.
- If state objects need no data, zero-sized structs keep the pattern lightweight in Go.
- Do not introduce State if a small switch is still simpler and unlikely to grow.
When to Use
- An object has a lifecycle with different allowed operations per stage.
- Status-based conditionals are spreading across the codebase.
- You want invalid transitions expressed close to the rules themselves.
When NOT to Use
- There are only a couple of stable branches and they are unlikely to grow.
- The transition logic is simpler as a small explicit switch.
- The extra objects would hide rather than clarify the workflow.