A vending machine responds to the same button differently while idle, holding credit, or out of stock. The input stays the same. Its current state decides what the input means.

The State pattern moves state-dependent behavior into objects that share one interface. A context such as Order holds its current state and delegates operations to it. Replacing PendingState with PaidState changes how Ship() behaves without spreading a status switch across every operation. Transitions may be initiated by the context or by a state object. The important boundary is that callers ask for domain operations instead of setting state directly. C# compiler-generated IAsyncStateMachine types use the same broad idea—saved state determines where MoveNext() resumes—though their generated switch is an implementation technique rather than a textbook object-oriented State hierarchy.

stateDiagram-v2
    [*] --> Pending
    Pending --> Paid : Payment confirmed
    Pending --> Cancelled : Customer cancels
    Paid --> Shipped : Warehouse dispatches
    Paid --> Cancelled : Refund before shipping
    Shipped --> Delivered : Carrier confirms
    Delivered --> Returned : Return window open

State vs Strategy

The class diagrams can look alike, but the reasons for change differ. State represents an internal mode and its valid transitions. Strategy represents a replaceable algorithm, usually selected from outside the context.

Problem

Order.Ship(), Order.Cancel(), Order.Refund() each have a massive switch on Status — scattered validation, easy to miss a transition:

public class Order
{
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
 
    // ⚠️ Every method repeats the same switch — adding a new status means editing all methods
    public void Ship()
    {
        switch (Status)
        {
            case OrderStatus.Paid:
                Status = OrderStatus.Shipped;
                break;
            case OrderStatus.Pending:
                throw new InvalidOperationException("Cannot ship unpaid order");
            case OrderStatus.Shipped:
                throw new InvalidOperationException("Order already shipped");
            case OrderStatus.Delivered:
                throw new InvalidOperationException("Order already delivered");
            case OrderStatus.Cancelled:
                throw new InvalidOperationException("Cannot ship cancelled order");
            // ⚠️ Adding OrderStatus.OnHold requires editing Ship(), Cancel(), Refund(), Deliver()
        }
    }
 
    public void Cancel()
    {
        switch (Status) // ⚠️ same switch, different transitions
        {
            case OrderStatus.Pending:
            case OrderStatus.Paid:
                Status = OrderStatus.Cancelled;
                break;
            case OrderStatus.Shipped:
                throw new InvalidOperationException("Cannot cancel shipped order — use return");
            // ...
        }
    }
}

Adding OrderStatus.OnHold means finding every operation that switches on status. Missing one leaves an inconsistent transition rule.

Solution

Each state becomes a class that knows its valid transitions:

// State interface — defines all operations the order can perform
public interface IOrderState
{
    void Pay(Order order);
    void Ship(Order order);
    void Deliver(Order order);
    void Cancel(Order order);
    void Return(Order order);
    string StatusName { get; }
}
 
// Concrete states — each knows only its own valid transitions
public class PendingState : IOrderState
{
    public string StatusName => "Pending";
 
    public void Pay(Order order) => order.TransitionTo(new PaidState()); // ✅ valid transition
    public void Ship(Order order) => throw new InvalidOperationException("Pay first");
    public void Deliver(Order order) => throw new InvalidOperationException("Pay and ship first");
    public void Cancel(Order order) => order.TransitionTo(new CancelledState()); // ✅ valid
    public void Return(Order order) => throw new InvalidOperationException("Order not yet delivered");
}
 
public class PaidState : IOrderState
{
    public string StatusName => "Paid";
 
    public void Pay(Order order) => throw new InvalidOperationException("Already paid");
    public void Ship(Order order) => order.TransitionTo(new ShippedState()); // ✅ valid
    public void Deliver(Order order) => throw new InvalidOperationException("Ship first");
    public void Cancel(Order order) => order.TransitionTo(new CancelledState()); // ✅ valid (with refund)
    public void Return(Order order) => throw new InvalidOperationException("Not yet delivered");
}
 
public class ShippedState : IOrderState
{
    public string StatusName => "Shipped";
 
    public void Pay(Order order) => throw new InvalidOperationException("Already paid");
    public void Ship(Order order) => throw new InvalidOperationException("Already shipped");
    public void Deliver(Order order) => order.TransitionTo(new DeliveredState()); // ✅ valid
    public void Cancel(Order order) => throw new InvalidOperationException("Use return process");
    public void Return(Order order) => throw new InvalidOperationException("Not yet delivered");
}
 
public class DeliveredState : IOrderState
{
    public string StatusName => "Delivered";
 
    public void Pay(Order order) => throw new InvalidOperationException("Already paid");
    public void Ship(Order order) => throw new InvalidOperationException("Already delivered");
    public void Deliver(Order order) => throw new InvalidOperationException("Already delivered");
    public void Cancel(Order order) => throw new InvalidOperationException("Use return process");
    public void Return(Order order) => order.TransitionTo(new ReturnedState()); // ✅ valid
}
 
// CancelledState and ReturnedState are omitted; they reject or define any later transitions.
 
// Adding OnHoldState = one new class plus edits to states that transition to or from it
public class OnHoldState : IOrderState
{
    public string StatusName => "OnHold";
 
    public void Pay(Order order) => throw new InvalidOperationException("Resolve hold first");
    public void Ship(Order order) => throw new InvalidOperationException("Resolve hold first");
    public void Deliver(Order order) => throw new InvalidOperationException("Resolve hold first");
    public void Cancel(Order order) => order.TransitionTo(new CancelledState());
    public void Return(Order order) => throw new InvalidOperationException("Not yet delivered");
}
 
// Context — delegates to current state
public class Order
{
    private IOrderState _state = new PendingState();
 
    public string Status => _state.StatusName;
 
    internal void TransitionTo(IOrderState newState)
    {
        _state = newState;
        // ✅ Raise event, log transition, etc.
    }
 
    // ✅ Delegates to state — no switch statements
    public void Pay() => _state.Pay(this);
    public void Ship() => _state.Ship(this);
    public void Deliver() => _state.Deliver(this);
    public void Cancel() => _state.Cancel(this);
    public void Return() => _state.Return(this);
}

OnHoldState localizes the new behavior. Existing states still need edits if they gain transitions to or from the new state, so the pattern does not eliminate all change. It keeps each change near the state that owns it.

Familiar state machines

Compiler-generated IAsyncStateMachine stores a numeric state and resumes MoveNext() at the corresponding continuation point. It demonstrates state-driven control flow, but it does so with generated fields and branching rather than interchangeable state objects.

Circuit breakers move among closed, open, and half-open modes. The same execution request is admitted, rejected, or used as a probe according to the current mode and transition policy.

TaskStatus exposes a task’s lifecycle state. It is an enum-backed state machine, which is often the better design when the transition table is small and behavior does not warrant separate classes.

Pitfalls

State explosion. Ten states behind an eight-method interface can produce 80 method bodies, many of which only reject an operation. A default base state can centralize rejection, while a library such as Stateless can express a transition-heavy machine as a table.

Incomplete transition effects. Changing Paid to Cancelled without arranging the refund creates a valid-looking state with broken business meaning. The transition boundary must define whether effects complete atomically, run through an outbox, or leave the machine in an intermediate state.

Persistence coupling. Persist a stable state identifier and reconstruct behavior after loading. Serializing concrete state objects ties stored data to the class hierarchy and makes refactoring much harder.

Tradeoffs

ConcernState patternEnum + switch
Adding a new stateAdd one class, then edit existing states whose transitions reference itEdit every switch that handles the state
Adding a new operationAdd method to interface + all state classesAdd one method with a switch
Valid transition enforcementEach state class defines its ownSwitch in each method
ComplexityMany small classesFew large methods
ReadabilityState behavior is localizedAll behavior in one class

State earns its classes when state-dependent rules are scattered across several operations and each mode owns meaningful behavior. A compact enum and one transition function remain easier to audit for a small machine. Stateless is useful when the transition table matters more than rich behavior inside each state.

Questions

References