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.
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.
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.
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.
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.
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
- Create:
dotnet new console -n StateLab
- Navigate:
cd StateLab
- Replace Program.cs
- Update .csproj
- Run:
dotnet run
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);
}
<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.