Card 07/ 09
A Context That Still Checks Which Strategy It Holds
The context switches on the strategy's type, and every new strategy edits the context again.
A context that tests which concrete strategy it holds, with isinstance or a type name, has brought back the conditional the pattern exists to remove. A new strategy then needs an edit to the context, and if nobody makes it, the new strategy falls into a branch written for another. This card shows how that happens, what it costs, and the fix.
A checkout prints a delivery note under the price. Each carrier's note is different, and the developer who added the note wrote it in the context. A same-day courier is then added as a new strategy, with its own cost_pence.
def delivery_note(self) -> str:
if isinstance(self._shipping, Standard):
return "3 to 5 working days"
elif isinstance(self._shipping, Express):
return "next working day"
else:
return "collect in 2 days"What note does the same-day courier get? Decide, then look.
class Standard:
def cost_pence(self, weight_kg: int) -> int:
return 399 + 100 * weight_kg
class Express:
def cost_pence(self, weight_kg: int) -> int:
return 899 + 200 * weight_kg
class SameDayCourier:
def cost_pence(self, weight_kg: int) -> int:
return 1499
class Checkout:
def __init__(self, shipping) -> None:
self._shipping = shipping
def delivery_note(self) -> str:
if isinstance(self._shipping, Standard):
return "3 to 5 working days"
elif isinstance(self._shipping, Express):
return "next working day"
else:
return "collect in 2 days"
for shipping in (Standard(), Express(), SameDayCourier()):
print(f"{type(shipping).__name__}: {Checkout(shipping).delivery_note()}")Standard: 3 to 5 working days
Express: next working day
SameDayCourier: collect in 2 daysThe courier is neither Standard nor Express, so the else treats it as a collection point, and a customer who paid for same-day delivery is told to collect the parcel in two days. Nothing fails, and no test of the courier's price would notice.
The cause is a piece of carrier-specific behaviour that was put in the context instead of the strategy. The context is again the place every new carrier must be edited, and the edit is easy to miss because adding the courier class worked and its price was right.
The fix is to move the varying part into the strategy: each carrier provides its own estimate, and the context asks for it.
class Standard:
estimate = "3 to 5 working days"
def cost_pence(self, weight_kg: int) -> int:
return 399 + 100 * weight_kg
class Express:
estimate = "next working day"
def cost_pence(self, weight_kg: int) -> int:
return 899 + 200 * weight_kg
class SameDayCourier:
estimate = "today, before 9 pm"
def cost_pence(self, weight_kg: int) -> int:
return 1499
class Checkout:
def __init__(self, shipping) -> None:
self._shipping = shipping
def delivery_note(self) -> str:
return self._shipping.estimate
for shipping in (Standard(), Express(), SameDayCourier()):
print(f"{type(shipping).__name__}: {Checkout(shipping).delivery_note()}")Standard: 3 to 5 working days
Express: next working day
SameDayCourier: today, before 9 pmA useful check when reviewing a strategy is to search the context for the names of the concrete strategies. Any mention other than in tests means behaviour that belongs to one strategy has leaked out of it.
← → move through sections and cards