State vs Switch/If Chains: The Refactor Path
What this concept is
Move lifecycle-specific behavior behind a state boundary while preserving the public protocol. Repeated conditions are a clue, not a numerical trigger. A switch, table and state hierarchy all encode some state-by-operation matrix; none changes that matrix's semantic obligations automatically.
Why it matters here
Replacing every rejection with ValueError('not allowed') changes behavior if callers observe error messages. Replacing public status with a private state object also changes the API. A visually tidy hierarchy is not sufficient evidence of a refactor. There is no general linear-versus-quadratic maintenance law here.
Concrete example
Here is one complete, small State implementation preserving a read-only status and the existing pay/ship error messages. Cancellation would be a separate tested extension rather than an implicit branch omitted from this sample.
class Placed:
label = 'placed'
def pay(self, order): order._state = Paid()
def ship(self, order): raise ValueError('not paid yet')
class Paid:
label = 'paid'
def pay(self, order): raise ValueError('already paid')
def ship(self, order): order._state = Shipped()
class Shipped:
label = 'shipped'
def pay(self, order): raise ValueError('already shipped')
def ship(self, order): raise ValueError('already shipped')
class Order:
def __init__(self): self._state = Placed()
@property
def status(self): return self._state.label
def pay(self): return self._state.pay(self)
def ship(self): return self._state.ship(self)
Before and after must have the same status reads, None success returns and exact errors for these operations. If the old API allowed clients to assign status, making it read-only is an intentional compatibility change. Either retain and validate that setter or negotiate its removal; do not silently narrow the contract.
Common confusion / misconception
Introducing a hierarchy alongside the old status field creates two authorities unless one is derived from the other. Converting callers to isinstance checks can recreate representation coupling. A stable status label is often a better compatibility boundary. Tests cover chosen traces; they do not prove every possible history preserves behavior.
How to use it
Write the public matrix first, including rejections. Establish one state authority and a compatible projection of public status. Route one operation at a time through the new representation; compare traces against a frozen baseline. Remove old branches only when all affected observations match. Compare a transition table before paying for a hierarchy, and record the actual change pressure.
Check yourself
Which observations would catch the generic-error bug? How can two state fields drift? If one new state changes incoming transitions, why is 'only add one class' an inaccurate promise?
Mini drill or application
Implement L04 twice, with a table and State objects, under its existing contract. Run all 16 state/event cases and generated event sequences against both. Retain one failed trace and explain why it was observable. P15 asks for the compatibility memo; no requirement to remove every switch.
Read this only if stuck
GoF book index, 081-state-consequences.md; Refactoring book index for Replace Conditional with Polymorphism. Reuse Module 2's observation-boundary method rather than treating green tests as a proof.