Every messy if/else chain I have ever met started out reasonable. Two cases, easy to read. Then a third shipped, then a fourth, and one afternoon you open the file and find this:
def calculate_shipping(order):
if order.method == "standard":
return order.weight * 0.5
elif order.method == "express":
return order.weight * 0.5 * 2 + 5
elif order.method == "overnight":
return order.weight * 0.5 * 3 + 15
elif order.method == "pickup":
return 0
else:
raise ValueError(f"unknown method: {order.method}")It works. But every new shipping method means editing this function, the branches keep growing, and testing one method means running the whole thing. This shape is the classic signal for the Strategy pattern: you are switching on a type to pick one of several interchangeable behaviours.
The idea in one line
Pull each branch out into its own object, and pick the object instead of running the branch.
That's the whole pattern. Each "strategy" is a self-contained way of doing the same job, and the caller holds one strategy without caring which concrete one it is.
The refactor
First, give every strategy the same shape. In Python a small base class (or a Protocol) is enough:
from typing import Protocol
class ShippingStrategy(Protocol):
def cost(self, order) -> float: ...Then each branch becomes its own class:
class StandardShipping:
def cost(self, order) -> float:
return order.weight * 0.5
class ExpressShipping:
def cost(self, order) -> float:
return order.weight * 0.5 * 2 + 5
class OvernightShipping:
def cost(self, order) -> float:
return order.weight * 0.5 * 3 + 15
class Pickup:
def cost(self, order) -> float:
return 0The if/else chain collapses into a lookup:
STRATEGIES: dict[str, ShippingStrategy] = {
"standard": StandardShipping(),
"express": ExpressShipping(),
"overnight": OvernightShipping(),
"pickup": Pickup(),
}
def calculate_shipping(order):
strategy = STRATEGIES.get(order.method)
if strategy is None:
raise ValueError(f"unknown method: {order.method}")
return strategy.cost(order)What actually got better
Adding a method no longer touches existing code. A new drone-delivery option is a new class and one new entry in the map. You never reopen StandardShipping to add it, so you can't break it by accident. That is the open/closed principle in practice: open to extension, closed to modification.
Each strategy is testable on its own. You can test ExpressShipping().cost(order) directly, without constructing every other case or threading through a giant function.
The behaviour becomes swappable at runtime. Because the caller just holds a ShippingStrategy, you can pick it based on config, an A/B test, or a feature flag, and swap it without rewriting the caller.
The names carry meaning. OvernightShipping says more than elif order.method == "overnight". The intent lives in a class name instead of a string comparison.
When you don't need it
I want to be honest here, because pattern-happy code is its own kind of mess.
Two or three stable branches? Leave the if/else. The pattern adds classes, a registry, and indirection. If the logic is small and rarely changes, that overhead buys you nothing. Reach for Strategy when the branches keep multiplying or each one is getting fat.
Branches that are one-liners with no logic are often better as plain functions in a dict than as full classes:
STRATEGIES = {
"standard": lambda o: o.weight * 0.5,
"pickup": lambda o: 0,
}Same pattern, less ceremony. Strategy is about interchangeable behaviour behind a common interface - whether that behaviour is a class or a function is your call.
How to spot it in your own code
The tell is almost always the same: an if/else or match that switches on a type, a mode, or a string, where every branch does the same kind of work in a different way. Shipping methods, payment providers, export formats, discount rules, notification channels. When you see one of those growing, that is Strategy knocking.
The takeaway
The Strategy pattern is not really about classes and interfaces. It is about noticing that a swelling if/else chain is a list of interchangeable behaviours, and giving each one a name and a home. Do that and adding the next case stops being a risk and becomes a two-line change.

