State
In short: State turns each state of an object (such as an order) into its own object that knows which actions it allows and which state comes next, instead of an if on the status in every method.
Problem
The order that checks its status everywhere
An order in the shop goes new → paid → shipped → delivered, and it can also be cancelled. The status is text, and every method checks it:
class Order{ public string Status { get; private set; } = "new";
public void Pay() { if (Status == "new") { Status = "paid"; } else { throw new InvalidOperationException($"The order is {Status}: it can't be paid"); } }
public void Ship() { if (Status == "cancelled") { throw new InvalidOperationException($"The order is {Status}: it can't be shipped"); } Status = "shipped"; }
public void Deliver() { if (Status == "shipped") { Status = "delivered"; } else { throw new InvalidOperationException($"The order is {Status}: it can't be delivered"); } }
public void Cancel() { if (Status == "delivered") { throw new InvalidOperationException($"The order is {Status}: it can't be cancelled"); } if (Status != "new") { Console.WriteLine("Refunding the customer"); } Status = "cancelled"; }}Two orders:
var first = new Order();first.Ship();Console.WriteLine("First: " + first.Status);
var second = new Order();second.Pay();second.Ship();second.Cancel();Console.WriteLine("Second: " + second.Status);First: shippedRefunding the customerSecond: cancelledThe first one was shipped without being paid: Ship only checks for "cancelled". The second one was cancelled while the parcel is on the road. The customer gets the money back, and the courier will still deliver it.
Nobody wrote a bug on purpose. Four events times five statuses are twenty combinations, and each method remembers only some of them. A new status "packed" means thinking again about all four methods, and the forgotten combination will still slip through quietly.
The idea
State: each state knows what it allows
The question "is this event allowed now?" has an answer that depends on the state. So the answer moves into the state itself.
Each state is an object of its own class. It says which events it allows and which state comes next. The common base class OrderState refuses every event with a clear error, and each state overrides only the allowed ones. The order checks nothing: it passes the event to the current state and remembers what that state returns.
This is the State pattern. It has three roles:
- the order keeps the current state. Books call this the context;
- the base class
OrderState: all the events, and each one refuses by default; - the concrete states:
NewState,PaidState,ShippedState,DeliveredState,CancelledState.
See it
The five states and the order
The base class: every event refuses with a text that includes the name of the state.
abstract class OrderState{ public abstract string Name { get; }
public virtual OrderState Pay() => throw Refuse("be paid"); public virtual OrderState Ship() => throw Refuse("be shipped"); public virtual OrderState Deliver() => throw Refuse("be delivered"); public virtual OrderState Cancel() => throw Refuse("be cancelled");
protected InvalidOperationException Refuse(string action) { return new InvalidOperationException($"The order is {Name}: it can't {action}"); }}Each state overrides only what it allows. Cancelling a paid order refunds the money, while cancelling a new one refunds nothing, because nothing has been paid. This behavior is in its own state, without an if:
class NewState : OrderState{ public override string Name => "new"; public override OrderState Pay() => new PaidState(); public override OrderState Cancel() => new CancelledState();}
class PaidState : OrderState{ public override string Name => "paid"; public override OrderState Ship() => new ShippedState();
public override OrderState Cancel() { Console.WriteLine("Refunding the customer"); return new CancelledState(); }}
class ShippedState : OrderState{ public override string Name => "shipped"; public override OrderState Deliver() => new DeliveredState();}
class DeliveredState : OrderState{ public override string Name => "delivered";}
class CancelledState : OrderState{ public override string Name => "cancelled";}The order keeps the current state and passes every event to it:
class Order{ private OrderState state = new NewState();
public string Status => state.Name;
public void Pay() => state = state.Pay(); public void Ship() => state = state.Ship(); public void Deliver() => state = state.Deliver(); public void Cancel() => state = state.Cancel();}The two combinations from the problem, and then the whole way:
var order = new Order();try{ order.Ship();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Pay();order.Ship();Console.WriteLine(order.Status);try{ order.Cancel();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Deliver();Console.WriteLine(order.Status);The order is new: it can't be shippedshippedThe order is shipped: it can't be cancelleddeliveredvar order = new Order();try{ order.Ship();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Pay();order.Ship();Console.WriteLine(order.Status);try{ order.Cancel();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Deliver();Console.WriteLine(order.Status);Step by step, in words
- A new order: its state field points to NewState ("new").
- Ship() on a new order. NewState has no Ship arrow, so the base class refuses and catch prints the error.
- Pay(): NewState allows it and returns PaidState. The order just remembers the new state.
- The same Ship() as before, but now PaidState answers: it allows it, and the next state is ShippedState.
- The status is the name of the current state: shipped.
- Cancel() while the parcel is on the road. ShippedState has no Cancel arrow: a wall, and no refund.
- Deliver(): from shipped to delivered.
- Nobody wrote a check for the two forbidden combinations. They have no arrow, so they are refused.
There is no Ship arrow from "new", so shipping is refused. There is no Cancel arrow from "shipped". Nobody wrote a check for these two combinations: they are forbidden because no state allowed them.
See it
A new event without touching the other states
The warehouse wants the parcel packed before it leaves: paid → packed → shipped. The new event goes into the base class, and it refuses like the others:
public virtual OrderState Pack() => throw Refuse("be packed");PaidState now allows Pack instead of Ship, and the new PackedState allows Ship and cancelling with a refund:
class PaidState : OrderState{ public override string Name => "paid"; public override OrderState Pack() => new PackedState();
public override OrderState Cancel() { Console.WriteLine("Refunding the customer"); return new CancelledState(); }}
class PackedState : OrderState{ public override string Name => "packed"; public override OrderState Ship() => new ShippedState();
public override OrderState Cancel() { Console.WriteLine("Refunding the customer"); return new CancelledState(); }}The order gets the method Pack, which only passes the event on:
public void Pack() => state = state.Pack();var order = new Order();try{ order.Pack();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}order.Pay();order.Pack();order.Ship();Console.WriteLine(order.Status);The order is new: it can't be packedshippedNewState, ShippedState, DeliveredState and CancelledState weren't touched, and packing is forbidden in them without you writing a line for it. A paid order can no longer be shipped directly, because PaidState has no Ship. With the chain of ifs, the new event had to cross your mind in every method.
See it
Without a class for each state: an enum and a table
Here, cancelling a paid order refunds the money: the state does something, and that belongs in a class. If the states don't do anything and only say which transition is allowed, less is enough.
Anenum is a type with a fixed list of values. The status is one of them, and the table says which statuses you can move to from each one:enum Status { New, Paid, Shipped, Delivered, Cancelled }
class Order{ private static readonly Dictionary<Status, Status[]> Next = new() { [Status.New] = [Status.Paid, Status.Cancelled], [Status.Paid] = [Status.Shipped, Status.Cancelled], [Status.Shipped] = [Status.Delivered], [Status.Delivered] = [], [Status.Cancelled] = [], };
public Status Status { get; private set; } = Status.New;
public void MoveTo(Status next) { if (!Next[Status].Contains(next)) { throw new InvalidOperationException($"{Status} → {next}: not allowed"); } Status = next; }}var order = new Order();order.MoveTo(Status.Paid);order.MoveTo(Status.Shipped);try{ order.MoveTo(Status.Cancelled);}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}Console.WriteLine(order.Status);Shipped → Cancelled: not allowedShippedThe check is in one place, and the transitions are data: one row in the table for a new status. This is the honest choice when the states have no behavior. Once some transition has to do something (refund the money, send an email, record when it was shipped), the table starts growing ifs around it, and then State is worth its classes.
More precisely, if you are curious
The states here have no fields, so one object of each would be enough for all orders. In C# this can be a static field with a ready object for each state (static readonly OrderState Paid = new PaidState();). This saves objects, but it has a cost: once a state gets a field (the amount to refund, the payment date), one shared object shares it between all orders. That's why new on every transition is the safer start.
Try it
Predict the output
The order is paid and cancelled right away, and then someone tries to ship it. What does the program print?
var order = new Order();order.Pay();order.Cancel();Console.WriteLine(order.Status);try{ order.Ship();}catch (InvalidOperationException e){ Console.WriteLine(e.Message);}Try it
Find the bug
Almost every state allows cancelling, so someone decides to write it once, in the base class:
abstract class OrderState{ public abstract string Name { get; }
public virtual OrderState Pay() => throw Refuse("be paid"); public virtual OrderState Ship() => throw Refuse("be shipped"); public virtual OrderState Deliver() => throw Refuse("be delivered"); public virtual OrderState Cancel() => new CancelledState();
protected InvalidOperationException Refuse(string action) { return new InvalidOperationException($"The order is {Name}: it can't {action}"); }}var order = new Order();order.Pay();order.Ship();order.Cancel();Console.WriteLine(order.Status);cancelledThe shipped order was cancelled without an error, and the parcel is on the road. Which line is the bug on?
Pick the line with the bug.
ShippedState and DeliveredState have no Cancel of their own, so they inherit the one from the base class, and that one now allows it. The default in the base class must refuse. Then a forgotten event stops with an error instead of slipping through quietly. What is allowed is written explicitly, in the state that allows it, the way NewState and PaidState do it. Put the refusal back on this line, and the shipped order says "The order is shipped: it can't be cancelled" again.
The idea
When you don't need it
State has a cost: one class for each state and a base class, instead of one field. It isn't worth it when:
- there are two states and one transition. "Paid or not" is one boolean field;
- the states have no behavior, only allowed transitions. Then an enum and a table from the previous card are enough;
- the transitions come from outside (the shop owner sets them up in the admin panel). Classes live in the code, while data can change without a new version of the program.
It is worth it when there are more states, each one does something of its own on an event, and new ones keep coming.
Exercise
Returns within 14 days
Solve the task in your editor.
Returns. The customer can return a delivered order, and the shop refunds the money. Add an event ReturnOrder and a state "returned". It is allowed only from "delivered". Take one order all the way to delivered and return it, then try to return a new order.
Refunding the customerreturnedThe order is new: it can't be returnedMore tasks
- History. The order remembers the statuses it went through and prints them like this:
new → paid → shipped → delivered. Where do you record the next status: in the order or in each state? - An email on every change. After every allowed transition the customer gets a line "Email: the order is paid". Make it so that a refused event sends no email.
- Packed with the table. Add "packed" to the version with the enum and the table. Count how many places you changed there and how many in the version with classes.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Throwing and your own exceptions · Dictionary and HashSet · Design patterns
- Continues in: The shop: which pattern where · Code smells: long method, large class, duplication, primitives instead of types, long if chains · Refactoring in small steps
- Tasks on this topic: Design patterns