Managing State Transitions with the State Pattern in C#

Replacing Switch Statements with Objects

If you've ever written a class with massive switch statements checking the current state before every operation, you've felt the pain of procedural state management. Adding a new state means updating every switch across the class. Miss one and you've got a bug.

The State pattern encapsulates state-specific behavior in separate classes. Instead of checking conditions, the context delegates to the current state object. Each state knows how to handle operations and which states it can transition to. This eliminates conditionals and centralizes state logic.

You'll build an order processing system where orders move through pending, confirmed, shipped, and delivered states. By the end, you'll know when state objects clean up your code and when simple enums suffice.

The Problem Without State Objects

Traditional state management uses an enum and switch statements. Every method checks the current state to decide what to do. As states multiply, these switches grow and spread across the codebase.

OrderWithSwitch.cs
namespace StateDemo;

public enum OrderStatus { Pending, Confirmed, Shipped, Delivered }

public class OrderWithSwitch
{
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;

    public void Confirm()
    {
        switch (Status)
        {
            case OrderStatus.Pending:
                Status = OrderStatus.Confirmed;
                Console.WriteLine("Order confirmed");
                break;
            default:
                throw new InvalidOperationException("Cannot confirm");
        }
    }

    public void Ship()
    {
        switch (Status)
        {
            case OrderStatus.Confirmed:
                Status = OrderStatus.Shipped;
                Console.WriteLine("Order shipped");
                break;
            default:
                throw new InvalidOperationException("Cannot ship");
        }
    }

    public void Deliver()
    {
        switch (Status)
        {
            case OrderStatus.Shipped:
                Status = OrderStatus.Delivered;
                Console.WriteLine("Order delivered");
                break;
            default:
                throw new InvalidOperationException("Cannot deliver");
        }
    }
}

Each method has a switch. Adding a new state or transition means touching multiple methods. The logic for what a state can do is scattered across the class instead of localized.

Defining State Behavior

Create an interface for state behavior. Each concrete state implements this interface with logic specific to that state. The context delegates operations to the current state object.

IOrderState.cs
namespace StateDemo;

public interface IOrderState
{
    void Confirm(OrderContext context);
    void Ship(OrderContext context);
    void Deliver(OrderContext context);
    string GetStatus();
}

The interface defines what operations states must support. Each state decides how to handle these operations or whether to throw exceptions for invalid transitions.

Implementing Concrete States

Each state class handles operations differently. States know which transitions are valid and update the context when transitioning. Invalid operations throw exceptions.

OrderStates.cs
namespace StateDemo;

public class PendingState : IOrderState
{
    public void Confirm(OrderContext context)
    {
        Console.WriteLine("Order confirmed");
        context.SetState(new ConfirmedState());
    }

    public void Ship(OrderContext context) =>
        throw new InvalidOperationException("Cannot ship pending order");

    public void Deliver(OrderContext context) =>
        throw new InvalidOperationException("Cannot deliver pending order");

    public string GetStatus() => "Pending";
}

public class ConfirmedState : IOrderState
{
    public void Confirm(OrderContext context) =>
        throw new InvalidOperationException("Already confirmed");

    public void Ship(OrderContext context)
    {
        Console.WriteLine("Order shipped");
        context.SetState(new ShippedState());
    }

    public void Deliver(OrderContext context) =>
        throw new InvalidOperationException("Cannot deliver unshipped order");

    public string GetStatus() => "Confirmed";
}

public class ShippedState : IOrderState
{
    public void Confirm(OrderContext context) =>
        throw new InvalidOperationException("Already shipped");

    public void Ship(OrderContext context) =>
        throw new InvalidOperationException("Already shipped");

    public void Deliver(OrderContext context)
    {
        Console.WriteLine("Order delivered");
        context.SetState(new DeliveredState());
    }

    public string GetStatus() => "Shipped";
}

public class DeliveredState : IOrderState
{
    public void Confirm(OrderContext context) =>
        throw new InvalidOperationException("Already delivered");

    public void Ship(OrderContext context) =>
        throw new InvalidOperationException("Already delivered");

    public void Deliver(OrderContext context) =>
        throw new InvalidOperationException("Already delivered");

    public string GetStatus() => "Delivered";
}

Each state handles valid transitions and rejects invalid ones. The transition logic lives in the state that knows when it's appropriate to change. No switches needed.

Building the Context

The context holds the current state and delegates operations to it. Clients interact with the context, not with state objects directly. The context's interface stays stable even as states change.

OrderContext.cs
namespace StateDemo;

public class OrderContext
{
    private IOrderState _state;

    public OrderContext()
    {
        _state = new PendingState();
    }

    public void SetState(IOrderState state)
    {
        _state = state;
    }

    public void Confirm() => _state.Confirm(this);
    public void Ship() => _state.Ship(this);
    public void Deliver() => _state.Deliver(this);
    public string GetStatus() => _state.GetStatus();
}

The context exposes simple methods that delegate to the current state. Callers don't know which state is active or how it handles operations. The state object makes all decisions.

Using the State Machine

Create the context and call methods. The context transitions automatically as you invoke operations. Invalid transitions throw exceptions that you can catch and handle.

Program.cs
using StateDemo;

var order = new OrderContext();
Console.WriteLine($"Status: {order.GetStatus()}");

order.Confirm();
Console.WriteLine($"Status: {order.GetStatus()}");

order.Ship();
Console.WriteLine($"Status: {order.GetStatus()}");

order.Deliver();
Console.WriteLine($"Status: {order.GetStatus()}");

try
{
    order.Ship();
}
catch (InvalidOperationException ex)
{
    Console.WriteLine($"Error: {ex.Message}");
}

The order flows through states automatically. Attempting an invalid transition throws an exception. The state objects enforce rules without the context needing conditionals.

Try It Yourself

Build a simple traffic light state machine. Lights cycle through red, yellow, and green with different durations and rules.

Steps

  1. Create: dotnet new console -n StateLab
  2. Navigate: cd StateLab
  3. Replace Program.cs
  4. Update .csproj
  5. Run: dotnet run
Program.cs
var light = new TrafficLight();
light.Change();
light.Change();
light.Change();

interface ILightState
{
    void Change(TrafficLight light);
    string Color { get; }
}

class RedState : ILightState
{
    public string Color => "Red";
    public void Change(TrafficLight light)
    {
        Console.WriteLine("Red -> Green");
        light.State = new GreenState();
    }
}

class GreenState : ILightState
{
    public string Color => "Green";
    public void Change(TrafficLight light)
    {
        Console.WriteLine("Green -> Yellow");
        light.State = new YellowState();
    }
}

class YellowState : ILightState
{
    public string Color => "Yellow";
    public void Change(TrafficLight light)
    {
        Console.WriteLine("Yellow -> Red");
        light.State = new RedState();
    }
}

class TrafficLight
{
    public ILightState State { get; set; } = new RedState();
    public void Change() => State.Change(this);
}
StateLab.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Console

Red -> Green
Green -> Yellow
Yellow -> Red

Refactoring to Production Quality

Step 1: Extract switch statements into state classes. Identify each distinct state and create a class for it. Move the case logic into the corresponding state's methods. This makes each state's behavior explicit.

Step 2: Add state validation and logging. Have states validate transitions and log when they occur. Use structured logging to track state changes with context like order IDs and timestamps. This gives you an audit trail.

Step 3: Consider state persistence. If your application restarts, you need to restore the current state. Serialize the state name or enum and recreate the state object on load. Avoid serializing state objects directly because they might contain references that don't survive serialization.

Troubleshooting

When should I use the State pattern vs enums?

Use State pattern when each state has complex behavior or different state transitions. Enums work fine for simple flags with shared logic. If you find yourself writing long switch statements on an enum in multiple methods, state objects encapsulate that behavior better.

How do I handle state transitions safely?

Let state objects decide which states they can transition to. Validate transitions in the state class before changing the context's state. Throw exceptions or return result objects for invalid transitions. This keeps transition rules with the states that know them.

What's the gotcha with state object lifecycles?

Decide if states are singletons or created per transition. Stateless state objects can be singletons and reused. States holding data need fresh instances each time. Mixing these approaches causes bugs when singleton states accidentally retain data from previous uses.

Back to Articles