Behavioral Intermediate 8 min read

State Pattern in C#: Order Lifecycles with Stateless

State pattern in C# / .NET 10: model an order's lifecycle as states with allowed transitions, using pattern matching or the Stateless library.

Table of contents
  1. What problem does the State pattern solve in C#?
  2. How does the textbook State hierarchy look?
  3. When does an enum plus pattern matching replace State?
  4. How does the Stateless library make State machines ergonomic?
  5. When does the State pattern misfire?
  6. How does State compare to Strategy and Memento?
  7. What does a real .NET 10 example look like?
  8. Where should you read next in this series?

The order's life: Created when the cart is checked out, Paid when the charge succeeds, Shipped when the warehouse books a courier, Delivered when the courier confirms drop-off. Cancelled may interrupt the chain at any point up to Shipped. Each transition has rules: you cannot ship an unpaid order, you cannot pay a cancelled one, and Delivered is terminal.

The first version of Order.MarkPaid() is one method with if (_status == OrderStatus.Cancelled) throw ...; if (_status == OrderStatus.Paid) throw ...; .... By the time you have ten methods and six transitions, the rules are scattered across the class and adding Refunded requires touching every method.

The State pattern is the answer. The order's behaviour depends on its state; each state knows which transitions it allows and what to do when one fires. In modern C# the textbook class hierarchy is often replaced by an enum plus Stateless, but the pattern is the underlying idea both implementations preserve.

What problem does the State pattern solve in C#?

The pattern earns its place when an object's allowed operations depend on which lifecycle stage it is in. Three concrete shapes:

  1. Domain lifecycles. Order, account, document approval — a finite set of states with rules about who may move where.
  2. Workflow engines. Tickets, leads, deployments. Each step has a guard: "tests must pass before the deploy step is enabled".
  3. UI mode toggles. Edit mode, view mode, locked mode. The same buttons are visible but their effect depends on the mode.

What is not a State problem: "I want to swap an algorithm" — that is Strategy. "I want to record each transition for replay" — that is paired with Command. State is specifically about behaviour governed by lifecycle stage.

How does the textbook State hierarchy look?

A common interface, one class per state, the order delegating to the current state:

public interface IOrderState
{
    IOrderState MarkPaid();
    IOrderState Ship();
    IOrderState Cancel();
    string Name { get; }
}

public sealed class Created : IOrderState
{
    public string Name => nameof(Created);
    public IOrderState MarkPaid() => new Paid();
    public IOrderState Ship()     => throw new InvalidOperationException("Cannot ship unpaid order");
    public IOrderState Cancel()   => new Cancelled();
}

public sealed class Paid : IOrderState
{
    public string Name => nameof(Paid);
    public IOrderState MarkPaid() => throw new InvalidOperationException("Already paid");
    public IOrderState Ship()     => new Shipped();
    public IOrderState Cancel()   => new Cancelled();
}

public sealed class Shipped : IOrderState
{
    public string Name => nameof(Shipped);
    public IOrderState MarkPaid() => throw new InvalidOperationException("Already paid");
    public IOrderState Ship()     => throw new InvalidOperationException("Already shipped");
    public IOrderState Cancel()   => throw new InvalidOperationException("Cannot cancel shipped order");
}

public sealed class Cancelled : IOrderState
{
    public string Name => nameof(Cancelled);
    public IOrderState MarkPaid() => throw new InvalidOperationException("Cancelled");
    public IOrderState Ship()     => throw new InvalidOperationException("Cancelled");
    public IOrderState Cancel()   => this;       // idempotent
}

public sealed class Order
{
    private IOrderState _state = new Created();
    public string Status => _state.Name;
    public void MarkPaid() => _state = _state.MarkPaid();
    public void Ship()     => _state = _state.Ship();
    public void Cancel()   => _state = _state.Cancel();
}

Each state class encodes the rules for that state. Adding a new operation (Refund) means adding the method on the interface and implementing it in every state — the compiler reminds you of all five.

The structural picture:

stateDiagram-v2
    [*] --> Created
    Created --> Paid : MarkPaid
    Created --> Cancelled : Cancel
    Paid --> Shipped : Ship
    Paid --> Cancelled : Cancel
    Shipped --> Delivered : Deliver
    Cancelled --> [*]
    Delivered --> [*]

The diagram is the documentation. The classes mirror it node-for-node.

When does an enum plus pattern matching replace State?

For domains where each state's behaviour is small and you mostly care about transitions, an enum and switch are honest:

public enum OrderStatus { Created, Paid, Shipped, Delivered, Cancelled }

public sealed class Order
{
    public OrderStatus Status { get; private set; } = OrderStatus.Created;

    public void MarkPaid() => Status = Status switch
    {
        OrderStatus.Created => OrderStatus.Paid,
        _ => throw new InvalidOperationException($"Cannot pay from {Status}")
    };

    public void Ship() => Status = Status switch
    {
        OrderStatus.Paid => OrderStatus.Shipped,
        _ => throw new InvalidOperationException($"Cannot ship from {Status}")
    };

    public void Cancel() => Status = Status switch
    {
        OrderStatus.Created or OrderStatus.Paid => OrderStatus.Cancelled,
        OrderStatus.Cancelled                   => OrderStatus.Cancelled,
        _ => throw new InvalidOperationException($"Cannot cancel from {Status}")
    };
}

This is the right shape when:

For more, promote to State classes — or to a state-machine library.

How does the Stateless library make State machines ergonomic?

Stateless is a small NuGet that turns the state-machine into a fluent configuration. Triggers, guards, on-entry callbacks, and a Mermaid graph generator are all built in:

public sealed class Order
{
    private readonly StateMachine<OrderStatus, OrderTrigger> _sm;

    public Order()
    {
        _sm = new StateMachine<OrderStatus, OrderTrigger>(OrderStatus.Created);

        _sm.Configure(OrderStatus.Created)
            .Permit(OrderTrigger.MarkPaid, OrderStatus.Paid)
            .Permit(OrderTrigger.Cancel,   OrderStatus.Cancelled);

        _sm.Configure(OrderStatus.Paid)
            .Permit(OrderTrigger.Ship,     OrderStatus.Shipped)
            .Permit(OrderTrigger.Cancel,   OrderStatus.Cancelled)
            .OnEntry(() => Console.WriteLine("Charge succeeded; reserve inventory"));

        _sm.Configure(OrderStatus.Shipped)
            .Permit(OrderTrigger.Deliver,  OrderStatus.Delivered);
    }

    public OrderStatus Status => _sm.State;
    public void MarkPaid() => _sm.Fire(OrderTrigger.MarkPaid);
    public void Ship()     => _sm.Fire(OrderTrigger.Ship);
    public void Deliver()  => _sm.Fire(OrderTrigger.Deliver);
    public void Cancel()   => _sm.Fire(OrderTrigger.Cancel);

    public string ToDotGraph() => Stateless.Graph.UmlDotGraph.Format(_sm.GetInfo());
}

public enum OrderTrigger { MarkPaid, Ship, Deliver, Cancel }

Three things this gives you for free:

For domains with non-trivial transitions, the library wins.

When does the State pattern misfire?

Three traps:

How does State compare to Strategy and Memento?

Pattern What it represents When to swap
State The current lifecycle stage of an object Internal transitions on events
Strategy One algorithm you can plug in Caller decides; usually injection time
Memento A frozen state snapshot you can restore Save/restore boundaries

The cleanest split: State is what the object is now; Strategy is how the object computes; Memento is what the object was. You frequently combine all three: a state machine, with each state delegating to a strategy, plus mementos for transactional rollback.

What does a real .NET 10 example look like?

A complete Stateless-based order with persistence. The state token is the persisted column; the state machine is rebuilt on load:

public enum OrderStatus  { Created, Paid, Shipped, Delivered, Cancelled }
public enum OrderTrigger { MarkPaid, Ship, Deliver, Cancel }

public sealed class Order
{
    private readonly StateMachine<OrderStatus, OrderTrigger> _sm;
    private OrderStatus _status;

    public Order(OrderStatus initial = OrderStatus.Created)
    {
        _status = initial;
        _sm = new StateMachine<OrderStatus, OrderTrigger>(
            stateAccessor:  () => _status,
            stateMutator:   s => _status = s);

        _sm.Configure(OrderStatus.Created)
            .Permit(OrderTrigger.MarkPaid, OrderStatus.Paid)
            .Permit(OrderTrigger.Cancel,   OrderStatus.Cancelled);

        _sm.Configure(OrderStatus.Paid)
            .Permit(OrderTrigger.Ship,     OrderStatus.Shipped)
            .Permit(OrderTrigger.Cancel,   OrderStatus.Cancelled);

        _sm.Configure(OrderStatus.Shipped)
            .Permit(OrderTrigger.Deliver,  OrderStatus.Delivered);
    }

    public OrderStatus Status => _status;
    public void MarkPaid() => _sm.Fire(OrderTrigger.MarkPaid);
    public void Ship()     => _sm.Fire(OrderTrigger.Ship);
    public void Deliver()  => _sm.Fire(OrderTrigger.Deliver);
    public void Cancel()   => _sm.Fire(OrderTrigger.Cancel);
}

// Persistence (EF Core)
public sealed class OrderRow
{
    public int Id { get; set; }
    public OrderStatus Status { get; set; } = OrderStatus.Created;
}

public sealed class OrderRepository
{
    private readonly AppDbContext _db;
    public OrderRepository(AppDbContext db) => _db = db;

    public async Task<Order> LoadAsync(int id, CancellationToken ct)
    {
        var row = await _db.Orders.FindAsync(new object[] { id }, ct)
            ?? throw new NotFoundException();
        return new Order(row.Status);
    }
}

// Test
[Fact]
public void Cannot_ship_unpaid_order()
{
    var sut = new Order();
    Assert.Throws<InvalidOperationException>(() => sut.Ship());
}

[Fact]
public void Paid_order_can_be_shipped()
{
    var sut = new Order(OrderStatus.Paid);
    sut.Ship();
    Assert.Equal(OrderStatus.Shipped, sut.Status);
}

The transitions are declared once. The persistence is a single column. Adding Refunded is two enum values, three permits, zero method bodies.

A practical takeaway: the State pattern's biggest payoff is making invalid transitions impossible to express. Whether you use class hierarchies, an enum switch, or Stateless, the goal is the same — code that lets MarkPaid() succeed only from the states where it is legal, and refuses anywhere else. Get that right and entire categories of bugs vanish.

Frequently asked questions

What is the difference between State and Strategy?
Strategy and State have the same shape: an interface implemented by several classes, swapped at runtime. The intent differs. Strategy chooses one algorithm among interchangeable peers — pick the discount calculator. State chooses the entire behaviour of an object based on its lifecycle stage — an order in Shipped state cannot be paid again because the type of the current state class forbids it.
When does a simple enum beat a State class hierarchy?
When transitions are few, behaviour per state is small, and you do not need exhaustiveness checks beyond compiler warnings. A switch expression on OrderStatus covers most CRUD-style domains. Promote to State classes when each state has meaningful behaviour, not just data, or when transitions need to be a first-class concept.
Should I use the Stateless library or roll my own state machine?
Use Stateless the moment you have more than four states or transitions with side effects. The library gives you OnEntry, OnExit, guard conditions, sub-states, and a textual diagram you can paste into PR descriptions. Hand-rolled is fine for the simplest cases but tips into 'reinventing Stateless' fast.
How do you persist a State machine across processes?
Persist the current state token (an enum value or string) plus any state-specific data. On rehydration, look up the right state class from the token. Stateless includes a StateAccessor / StateMutator callback pair for exactly this case. Avoid serialising the state machine itself — it usually contains delegates that do not survive a round trip.