Skip to main content

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.