Skip to content
wannacode

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:
def __init__(self):
self.status = "new"
def pay(self):
if self.status == "new":
self.status = "paid"
else:
raise RuntimeError(f"The order is {self.status}: it can't be paid")
def ship(self):
if self.status == "cancelled":
raise RuntimeError(f"The order is {self.status}: it can't be shipped")
self.status = "shipped"
def deliver(self):
if self.status == "shipped":
self.status = "delivered"
else:
raise RuntimeError(f"The order is {self.status}: it can't be delivered")
def cancel(self):
if self.status == "delivered":
raise RuntimeError(f"The order is {self.status}: it can't be cancelled")
if self.status != "new":
print("Refunding the customer")
self.status = "cancelled"

Two orders:

first = Order()
first.ship()
print("First: " + first.status)
second = Order()
second.pay()
second.ship()
second.cancel()
print("Second: " + second.status)
Output
First: shipped
Refunding the customer
Second: cancelled

The 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.
Here in Python too, the states are classes, not functions: each one has several events, and the base class gives the refusal to all of them at once. The simpler form without classes is in the card "Without a class for each state".

See it

The five states and the order

The base class: every event refuses with a text that includes the name of the state.

class OrderState:
name = ""
def pay(self):
raise self.refuse("be paid")
def ship(self):
raise self.refuse("be shipped")
def deliver(self):
raise self.refuse("be delivered")
def cancel(self):
raise self.refuse("be cancelled")
def refuse(self, action):
return RuntimeError(f"The order is {self.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):
name = "new"
def pay(self):
return PaidState()
def cancel(self):
return CancelledState()
class PaidState(OrderState):
name = "paid"
def ship(self):
return ShippedState()
def cancel(self):
print("Refunding the customer")
return CancelledState()
class ShippedState(OrderState):
name = "shipped"
def deliver(self):
return DeliveredState()
class DeliveredState(OrderState):
name = "delivered"
class CancelledState(OrderState):
name = "cancelled"

The order keeps the current state and passes every event to it:

class Order:
def __init__(self):
self.state = NewState()
@property
def status(self):
return self.state.name
def pay(self):
self.state = self.state.pay()
def ship(self):
self.state = self.state.ship()
def deliver(self):
self.state = self.state.deliver()
def cancel(self):
self.state = self.state.cancel()

The two combinations from the problem, and then the whole way:

order = Order()
try:
order.ship()
except RuntimeError as e:
print(e)
order.pay()
order.ship()
print(order.status)
try:
order.cancel()
except RuntimeError as e:
print(e)
order.deliver()
print(order.status)
Output
The order is new: it can't be shipped
shipped
The order is shipped: it can't be cancelled
delivered

order = Order()
try:
order.ship()
except RuntimeError as e:
print(e)
order.pay()
order.ship()
print(order.status)
try:
order.cancel()
except RuntimeError as e:
print(e)
order.deliver()
print(order.status)
Step by step, in words
  1. A new order: its state field points to NewState ("new").
  2. ship() on a new order. NewState has no ship arrow, so the base class refuses and catch prints the error.
  3. pay(): NewState allows it and returns PaidState. The order just remembers the new state.
  4. The same ship() as before, but now PaidState answers: it allows it, and the next state is ShippedState.
  5. The status is the name of the current state: shipped.
  6. cancel() while the parcel is on the road. ShippedState has no cancel arrow: a wall, and no refund.
  7. deliver(): from shipped to delivered.
  8. 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:

def pack(self):
raise self.refuse("be packed")

PaidState now allows pack instead of ship, and the new PackedState allows ship and cancelling with a refund:

class PaidState(OrderState):
name = "paid"
def pack(self):
return PackedState()
def cancel(self):
print("Refunding the customer")
return CancelledState()
class PackedState(OrderState):
name = "packed"
def ship(self):
return ShippedState()
def cancel(self):
print("Refunding the customer")
return CancelledState()

The order gets the method pack, which only passes the event on:

def pack(self):
self.state = self.state.pack()
order = Order()
try:
order.pack()
except RuntimeError as e:
print(e)
order.pay()
order.pack()
order.ship()
print(order.status)
Output
The order is new: it can't be packed
shipped

NewState, 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.

An Enum (from the enum module) is a type with a fixed list of values, and auto() numbers them for you. The status is one of them, and the dictionary says which statuses you can move to from each one:
class Status(Enum):
NEW = auto()
PAID = auto()
SHIPPED = auto()
DELIVERED = auto()
CANCELLED = auto()
NEXT = {
Status.NEW: {Status.PAID, Status.CANCELLED},
Status.PAID: {Status.SHIPPED, Status.CANCELLED},
Status.SHIPPED: {Status.DELIVERED},
Status.DELIVERED: set(),
Status.CANCELLED: set(),
}
class Order:
def __init__(self):
self.status = Status.NEW
def move_to(self, next_status):
if next_status not in NEXT[self.status]:
raise RuntimeError(f"{self.status.name} → {next_status.name}: not allowed")
self.status = next_status
order = Order()
order.move_to(Status.PAID)
order.move_to(Status.SHIPPED)
try:
order.move_to(Status.CANCELLED)
except RuntimeError as e:
print(e)
print(order.status.name)
Output
SHIPPED → CANCELLED: not allowed
SHIPPED

The 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. Then each state can be one ready value in the module instead of a new object on every transition. 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?

order = Order()
order.pay()
order.cancel()
print(order.status)
try:
order.ship()
except RuntimeError as e:
print(e)
Answer

Try it

Find the bug

Almost every state allows cancelling, so someone decides to write it once, in the base class:

class OrderState:
name = ""
def pay(self):
raise self.refuse("be paid")
def ship(self):
raise self.refuse("be shipped")
def deliver(self):
raise self.refuse("be delivered")
def cancel(self):
return CancelledState()
def refuse(self, action):
return RuntimeError(f"The order is {self.name}: it can't {action}")
order = Order()
order.pay()
order.ship()
order.cancel()
print(order.status)
Output
cancelled

The 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.

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 return_order 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.

Output
Refunding the customer
returned
The order is new: it can't be returned

More 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

1. The shop adds a "pack" event. What do you write in the states where it is not allowed?
2. What does the order keep with State?
3. Someone makes cancelling allowed by default, in the base class, so it doesn't have to be written in every state. What happens to a shipped order?
4. When is an enum and a transition table enough, without a class for each state?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·