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
- What problem does the State pattern solve in C#?
- How does the textbook State hierarchy look?
- When does an enum plus pattern matching replace State?
- How does the Stateless library make State machines ergonomic?
- When does the State pattern misfire?
- How does State compare to Strategy and Memento?
- What does a real .NET 10 example look like?
- 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:
- Domain lifecycles. Order, account, document approval — a finite set of states with rules about who may move where.
- Workflow engines. Tickets, leads, deployments. Each step has a guard: "tests must pass before the deploy step is enabled".
- 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:
- Each state's behaviour is just "which transitions are allowed" — no rich behaviour beyond the next status.
- The number of states is small (under 5–6) and unlikely to grow.
- You want to persist the status as a single column and round-trip it cleanly.
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:
- Compile-time-ish guarantees.
Fireof an unsupported trigger throws aInvalidOperationExceptionwith a clear message; the configuration is a single source of truth. - OnEntry / OnExit hooks. Side effects run automatically when a transition lands; you do not scatter them across method bodies.
- Diagram export.
ToDotGraph()round-trips into Mermaid and is fantastic for PR reviews.
For domains with non-trivial transitions, the library wins.
When does the State pattern misfire?
Three traps:
- State explosion. Twelve combinations of "paid + shipped + cancelled-mid-flight + tax-pending" become twelve subclasses. Look for orthogonal axes; they often hint at two state machines, not one giant one.
- State classes with hidden mutable shared state. A
Paidinstance keeps a counter shared withShipped. The classes are state but the counter is not. Move shared data to the originator (the order); leave only behaviour in the state classes. - Serialising state objects. State objects often contain delegates or non-serialisable references. Persist the state token (enum or string) plus relevant data; rehydrate the object on load.
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.
Where should you read next in this series?
- Previous: Observer — direct publisher/subscriber notifications.
- Next: Strategy — same shape, different intent.
- Cross-reference: Command — pair with State to record every transition for audit and replay.
- Cross-reference: Memento — capture state before a risky transition; restore on rollback.
- Decision tree: How to choose the right design pattern.
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?
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?
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?
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?
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.