State vs Switch/If Chains: The Refactor Path
What This Concept Is
Most code with a nontrivial lifecycle starts as a status field plus a switch in every method. The State pattern is usually arrived at by refactoring, not by starting from scratch. This concept is the playbook.
Smell: repeated switches (Fowler). You see the same switch (status) in three or more methods. Adding a new state means changing all of them. Adding a new operation means writing yet another parallel switch.
The move: Replace Conditional with Polymorphism, specialized for lifecycle. Each state becomes a class; each switch branch becomes a method on the corresponding class.
Why It Matters Here
Most real state-machine code never gets refactored because the first switch "isn't that bad." Then five more methods join it. The cost to refactor grows linearly; the cost to leave it grows quadratically with (states × operations).
Being able to do this refactor in small, tested steps is the whole value.
Concrete Example
Before: switch-based order lifecycle.
class Order:
def __init__(self): self.status = "placed"
def pay(self):
if self.status == "placed": self.status = "paid"
elif self.status == "paid": raise ValueError("already paid")
elif self.status == "shipped": raise ValueError("already shipped")
else: raise ValueError("cannot pay")
def ship(self):
if self.status == "paid": self.status = "shipped"
elif self.status == "placed": raise ValueError("not paid yet")
elif self.status == "shipped": raise ValueError("already shipped")
else: raise ValueError("cannot ship")
def cancel(self):
if self.status == "placed": self.status = "canceled"
elif self.status == "paid": self.status = "canceled"
elif self.status == "shipped": raise ValueError("cannot cancel shipped")
else: raise ValueError("already canceled")
Three methods, three parallel if chains, same state vocabulary. After, using State:
class OrderState:
def pay(self, order): raise ValueError("not allowed")
def ship(self, order): raise ValueError("not allowed")
def cancel(self, order): raise ValueError("not allowed")
class Placed(OrderState):
def pay(self, order): order.state = Paid()
def cancel(self, order): order.state = Canceled()
class Paid(OrderState):
def ship(self, order): order.state = Shipped()
def cancel(self, order): order.state = Canceled()
class Shipped(OrderState): pass # all operations rejected
class Canceled(OrderState): pass
class Order:
def __init__(self): self.state = Placed()
def pay(self): self.state.pay(self)
def ship(self): self.state.ship(self)
def cancel(self): self.state.cancel(self)
Adding a Refunded state is one new class and a transition from Shipped. No other state changes.
Common Confusion / Misconception
- "A single switch is fine." The cost is not the first switch; it is the second, third, and every new operation forever. If the number of operations is likely to grow, refactor early.
- Refactoring in one big leap. Replace Conditional with Polymorphism should be incremental: introduce the state hierarchy, keep the old switches, route one method through the state, delete the corresponding branches, repeat.
- "Every enum with a switch should be State." No. If there are two branches and no transitions between states beyond trivial assignment, the switch is clearer. State pattern shines when both operations and transitions are non-trivial.
- Confusing type tags with state. An order's
type("retail" vs "wholesale") is not a state -- it never changes. Use a strategy or a plain subclass there; the lifecycle is something else.
How To Use It
Refactor protocol:
- Write tests covering current valid and invalid transitions. Must stay green through every step.
- Introduce the state hierarchy as empty classes; do not use them yet.
- Replace the first method. Leave the switch in place but have it delegate to
self.state.methodName(self). - Move one branch at a time. For each branch of the switch, implement it on the corresponding state class. Remove the branch. Run tests.
- Repeat for every method. Each time a method is empty of conditional logic, move on.
- Remove the status string. Replace any reads of
statuswith eitherisinstancechecks or by exposingstate.label. - Clean up. Inline or remove now-dead helpers.
Check Yourself
- What smell drives this refactor, and why is it named "repeated switches"?
- Why is the refactor safer when done in small steps protected by tests?
- When should you not refactor a switch to State?
- How do you keep existing callers working during the refactor?
Mini Drill or Application
Take the following switch-heavy class and refactor it over four steps, with tests green at each:
Documentwith statusesdraft,review,published,archived- methods
submit,approve,publish,archive-- each a 4-way switch
Expected outcome: one class per state, each containing only the methods that are valid in that state.