An order that’s been delivered can be refunded but not shipped. A cancelled order can’t do anything at all. Most systems track this with a status string and a lot of if statements. State turns each status into an object that knows exactly what’s allowed from where it stands.
| Category | Behavioral |
| Smell fixed | S20: Order transitions branch on status strings |
| Code | patterns/behavioral/state |
The Scene
The trading day deliberately tries a couple of things that shouldn’t work:
[061] admin.cancel customer=cu-3 order=o-1001
cancelled o-1001
[062] order.ship order=o-1001
rejected: cannot ship from Cancelled
[071] order.deliver order=o-1003
rejected: cannot deliver from Paid
You can’t ship a cancelled order, and you can’t deliver something that hasn’t shipped yet. Both rejections are correct. The question is how the code knows.
The Smell
Every lifecycle method in OrderService starts by comparing the status string:
public string? Ship(Order order)
{
// SMELL S20: the allowed transition is selected by comparing the status string
if (order.Status != "Paid")
{
return "cannot ship from " + order.Status;
}
order.Status = "Shipped";
There are five of these methods: Pay, Ship, Deliver, Cancel and Refund. Some of the checks are compound, like refund:
if (order.Status != "Paid" && order.Status != "Shipped" && order.Status != "Delivered")
{
return "cannot refund from " + order.Status;
}
The rules of the order lifecycle are real business rules, and right now they’re spread across five methods as string comparisons. Want to see everything a Paid order can do? Read all five methods and work it out. Add a new status like “On hold”? Revisit every check, and hope you don’t miss one. A typo like "Shiped" compiles fine.
The Idea
State turns each status into its own class. The order (the context) holds its current state object and passes every lifecycle request to it. Each state class implements only the transitions it allows, and moves the order to the next state.
Think of a traffic light. When it’s red, pressing the pedestrian button means one thing. When it’s green, it means something else. The button doesn’t change. The light’s current state decides what pressing it does.
The Refactor
The base state says no to everything by default:
internal abstract class OrderState
{
public abstract string Name { get; }
public virtual string? Pay(OrderService service, Order order) => Cannot("pay", order);
public virtual string? Ship(OrderService service, Order order) => Cannot("ship", order);
That default is the cleverest part. Each concrete state only overrides the transitions it allows. Everything else is automatically rejected. Here’s everything a paid order can do, in one place:
internal sealed class PaidOrderState : OrderState
{
public override string Name => "Paid";
public override string? Ship(OrderService service, Order order)
{
order.State = new ShippedOrderState();
service.AssignTrackingNumber(order);
service.RecordStatusChange(order);
return null;
}
And OrderService just delegates to whatever state the order is in:
public string? Pay(Order order)
{
return order.State.Pay(this, order);
}
CancelledOrderState and RefundedOrderState don’t override anything at all. They’re end states, and the class shows it: there’s nothing in them.
Put together, the whole lifecycle becomes a picture you can actually draw:
stateDiagram-v2
[*] --> Pending
Pending --> Paid : Pay
Pending --> Cancelled : Cancel
Paid --> Shipped : Ship
Paid --> Cancelled : Cancel
Paid --> Refunded : Refund
Shipped --> Delivered : Deliver
Shipped --> Refunded : Refund
Delivered --> Refunded : Refund
Cancelled --> [*]
Refunded --> [*]
Each arrow in that diagram is one overridden method in one state class. Before the refactor, drawing this diagram meant reverse-engineering five methods of string comparisons.
One more detail: Order.Status still exists as a string, but now it’s just the current state’s Name. Reports and notifications that print the status didn’t have to change.
What It Cost
11 files: about 285 lines added and 65 removed. Six state classes plus the base class, and tests for both the legal and the illegal transitions.
When Not to Use It
- There are only two or three states and simple rules. An enum and a
switchexpression can be clearer than six classes. C#’s pattern matching makes small state machines quite readable. - The states don’t change behaviour. If the status is just a label for reporting and every operation works the same regardless, there’s nothing for state classes to do.
- You need a workflow engine. If states and transitions are configured by the business, persisted, versioned and audited, a dedicated state machine library or workflow tool will serve you better than hand-written classes.
Easily Confused With
Strategy. The class diagrams are nearly identical: a context delegating to an interchangeable object. The difference is who changes it and why. With Strategy, the client picks an algorithm, like a shipping-cost calculation. With State, the object changes its own state as its lifecycle moves forward, and states usually know about each other. See Strategy vs State, and the Strategy post next.
Try It Yourself
dotnet build patterns/behavioral/state/State.slnx
dotnet test patterns/behavioral/state/State.slnx
dotnet run --project patterns/behavioral/state/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Exercise: add an OnHoldOrderState that a paid order can move into and out of. Notice that you never touch the transitions of any other state, and that an on-hold order automatically rejects being shipped.
The State README has the participant table and a sequence diagram. The full change is in DIFF.md.