Design PatternsAdvanced 13 min Lesson 12 of 13

State — Behaviour That Changes With Status

When an object behaves differently depending on its status, give each status its own class and make invalid transitions impossible.

Design Patterns · Lesson 12 of 13
0/13 done(0%)

What is it? #

The State pattern gives each status of an object its own class, and lets the object delegate behaviour to whichever state it currently holds.

The problem is the status conditional that appears in every method. if self.status == "draft" ... elif "paid" ... elif "shipped" repeated across eight methods, each slightly out of date with the others.

With the pattern, each state class knows what is allowed in that state and which state comes next. Invalid transitions are impossible rather than merely discouraged.

It is closely related to Strategy — both delegate to an interchangeable object. The difference is that a state decides the next state, while a strategy is chosen from outside and does not change itself.

Think of it like this #

A traffic light. Red, amber and green each know exactly what comes next, and there is no central controller holding a big conditional about every combination.

Each light state has one job and one successor, which is why the system is easy to reason about.

Simple example #

An order moves through draft, paid, shipped, delivered and cancelled. Each stage allows different actions: you can cancel a paid order but not a delivered one, and you can only ship an order that has been paid.

Code #

PYTHON
from abc import ABC, abstractmethod


class OrderState(ABC):
    name: str

    def pay(self, order): raise ValueError(f"cannot pay an order that is {self.name}")
    def ship(self, order): raise ValueError(f"cannot ship an order that is {self.name}")
    def deliver(self, order): raise ValueError(f"cannot deliver an order that is {self.name}")
    def cancel(self, order): raise ValueError(f"cannot cancel an order that is {self.name}")


class Draft(OrderState):
    name = "draft"
    def pay(self, order):
        order.state = Paid()
    def cancel(self, order):
        order.state = Cancelled()


class Paid(OrderState):
    name = "paid"
    def ship(self, order):
        order.state = Shipped()
    def cancel(self, order):
        order.refund_issued = True          # cancelling a paid order refunds
        order.state = Cancelled()


class Shipped(OrderState):
    name = "shipped"
    def deliver(self, order):
        order.state = Delivered()


class Delivered(OrderState):
    name = "delivered"                      # terminal: everything raises


class Cancelled(OrderState):
    name = "cancelled"                      # terminal


class Order:
    def __init__(self, order_id):
        self.id = order_id
        self.state: OrderState = Draft()
        self.refund_issued = False

    @property
    def status(self) -> str:
        return self.state.name

    def pay(self): self.state.pay(self)
    def ship(self): self.state.ship(self)
    def deliver(self): self.state.deliver(self)
    def cancel(self): self.state.cancel(self)


order = Order("A-1")
print(order.status)          # draft
order.pay()
order.ship()
print(order.status)          # shipped

try:
    order.cancel()           # not allowed once shipped
except ValueError as exc:
    print(exc)               # cannot cancel an order that is shipped

order.deliver()
print(order.status)          # delivered
TEXT
draft ──pay──> paid ──ship──> shipped ──deliver──> delivered
  │              │
cancel         cancel (+refund)
  │              │
  └──> cancelled <┘

How it works #

OrderState provides a default for every action: refuse it, with a message naming the current state. Each concrete state then overrides only the transitions it permits.

That default-deny approach is the key design choice. Forgetting to handle an action means it is refused, which is the safe outcome. The opposite default would silently allow invalid transitions.

Draft.pay simply assigns a new state object. There is no table of allowed transitions to keep in sync — the knowledge lives in the state that owns it.

Paid.cancel shows state-specific side effects: cancelling after payment issues a refund, while cancelling a draft does not. In a conditional-based version, that difference is easy to miss.

Delivered and Cancelled override nothing, so every action raises. Terminal states cost one line each.

Order is thin. It holds the current state and forwards calls to it. The status property exposes the name for display and storage.

In a real system you would persist status as a string and rebuild the state object on load, with a small mapping from name to class.

Real-world use #

Order and payment workflows, support ticket lifecycles, content publishing (draft, review, published, archived), subscription status, and document approval flows are all state machines.

Getting this wrong has real consequences: shipping a cancelled order, refunding twice, or publishing a document that never passed review. Making invalid transitions impossible in code is far more reliable than remembering to check.

Many teams start with a status column and conditionals, which is fine for three states with two transitions. The pattern earns its place when transitions have their own rules and side effects.

There are also dedicated state machine libraries that add transition hooks, validation and diagram generation. They implement this same idea with more features around it.

Common mistakes #

  • Allowing actions by default instead of refusing them, so gaps become bugs.
  • Putting the transition table in the context class, which recreates the central conditional.
  • Storing state objects directly in the database instead of the status name.
  • Letting states hold business data, so switching state loses information.
  • Applying it to a two-state flag where a boolean is clearer.

Practice #

Model a support ticket with states open, in-progress, waiting-on-customer, resolved and closed. Define which transitions are allowed, implement them with the State pattern, and make resolving a ticket record a timestamp. Then prove that a closed ticket cannot be moved back to open.

Quick quiz

  1. 1. What does the State pattern replace?

  2. 2. How does State differ from Strategy?

  3. 3. Why should the base state refuse every action by default?

  4. 4. What should be stored in the database?

  5. 5. When is this pattern overkill?

Summary

  • Each status becomes a class that knows what is allowed and what comes next.
  • Default-deny in the base state makes gaps safe.
  • Transition side effects live with the state that owns them.
  • Persist the status name and rebuild the state object on load.
  • Use it when transitions have real rules, not for simple flags.