Strategy
In short: Strategy puts each rule (such as a payment method) in its own function, so you add new rules without touching the code that uses them.
Problem
The checkout that knows every payment method
The online shop takes five payment methods. Cash on delivery costs €2.50 more, a voucher takes €10 off, a bank transfer gets a 2% discount, and paying in installments costs 5% more. The checkout computes the sum like this:
def total(method, amount): if method == "card": return amount elif method == "cash on delivery": return amount + 2.50 elif method == "voucher": return max(amount - 10, 0) elif method == "bank transfer": return amount * 0.98 elif method == "installments": return amount * 1.05 raise ValueError("Unknown payment method: " + method)print(f"Card: €{total("card", 80):.2f}")print(f"Cash on delivery: €{total("cash on delivery", 80):.2f}")print(f"Voucher: €{total("voucher", 80):.2f}")print(f"Bank transfer: €{total("bank transfer", 80):.2f}")print(f"Installments: €{total("installments", 80):.2f}")Card: €80.00Cash on delivery: €82.50Voucher: €70.00Bank transfer: €78.40Installments: €84.00It works. But every month a new method arrives, and each one is a new branch in this function. The same chain is in the invoice and in the confirmation email too. Someone adds Apple Pay in two of the three places, and the customer gets an email saying "Unknown payment method".
Vouchers aren't only for €10 any more. To pass the value in, the function gets a third parameter, and every place that calls it changes, even for card payments, where there is no voucher. The code that every payment goes through is touched with every change.
The idea
Strategy: the rule on its own, the checkout asks
What changes is one thing: how the sum is computed. What doesn't change is the checkout: it takes the sum, asks and rounds.
So the rule moves into a separate function. All methods share one common shape: they take a sum and return how much to pay. The checkout keeps the function in a field and calls it without knowing which function it is.This is the Strategy pattern. It has three roles:
- the checkout keeps the strategy and calls it. Books call this the context;
- the common shape: a function that takes a sum and returns a sum;
- the concrete strategies: one function for each method.
A new method is a new strategy. The checkout isn't touched.
See it
The checkout and three methods
voucher(10) makes a strategy that remembers its €10. This way the value isn't a parameter of every method:def card(amount): return amount
def cash_on_delivery(amount): return amount + 2.50
def voucher(value): return lambda amount: max(amount - value, 0)method and calls it. Assigning checkout.method changes it:class Checkout: def __init__(self, method): self.method = method
def total(self, amount): return round(self.method(amount), 2)checkout = Checkout(card)print(f"Card: €{checkout.total(80):.2f}")
checkout.method = cash_on_deliveryprint(f"Cash on delivery: €{checkout.total(80):.2f}")
checkout.method = voucher(10)print(f"Voucher: €{checkout.total(80):.2f}")Card: €80.00Cash on delivery: €82.50Voucher: €70.00checkout = Checkout(card)print(f"Card: €{checkout.total(80):.2f}")
checkout.method = cash_on_deliveryprint(f"Cash on delivery: €{checkout.total(80):.2f}")
checkout.method = voucher(10)print(f"Voucher: €{checkout.total(80):.2f}")Step by step, in words
- The checkout gets a card: its method field points to the function card.
- total(80): the checkout doesn't know the method. It just asks the strategy for the sum and gets 80.00 back.
- A new method. Assigning checkout.method points the field to the function cash_on_delivery. Not a single line of Checkout changes.
- The same total(80), a different answer: 82.50.
- voucher(10) returns a function that remembers value = 10. Nothing points to the old functions any more.
- The voucher takes 10 off: 70.00. Three methods, and the checkout is the same object with the same code the whole time.
The checkout is one object with one piece of code for the whole program. Only what the field points to changes.
See it
A new method without touching the checkout
Bank transfer and installments come as two new functions:
def bank_transfer(amount): return amount * 0.98
def installments(amount): return amount * 1.05checkout = Checkout(bank_transfer)print(f"Bank transfer: €{checkout.total(80):.2f}")
checkout.method = installmentsprint(f"Installments: €{checkout.total(80):.2f}")Bank transfer: €78.40Installments: €84.00Not a single line of the checkout changed. You can't break card payments while adding transfers, because the card code isn't in front of you.
Here there is no compiler to check the shape. If the new function takes two values instead of one, the error only shows up when the checkout calls it. So every new strategy is run at least once before it reaches a customer.
See it
Which method the customer chooses
The customer types the method as text. The chain of ifs that picked the rule becomes a table: name → strategy.
METHODS = { "card": card, "cash on delivery": cash_on_delivery, "voucher": voucher(10),}
choice = input()method = METHODS.get(choice)if method is None: print("No such payment method: " + choice)else: checkout = Checkout(method) print(f"{choice}: €{checkout.total(80):.2f}")cash on deliverycash on delivery: €82.50An unknown method never reaches the checkout:
PayPalNo such payment method: PayPalThe choice is still in one place, but now it is data. A new method is a new row in the table and a new function.
More precisely, if you are curious
Why a function and not a class? In Python a function is a value: it is kept in a field and in a table like any other object. A strategy with one method is exactly a function. A class (or an object with methods) pays off when the strategy does more than one thing: for example a sum and a line for the invoice. In Java and C# the same is written with an interface and one class per method.
Try it
Predict the output
The order is €8, and the voucher is €10. Then the checkout switches to cash on delivery. What does the program print?
checkout = Checkout(voucher(10))print(f"€{checkout.total(8):.2f}")
checkout.method = cash_on_deliveryprint(f"€{checkout.total(8):.2f}")Try it
Find the bug
A voucher should be used once. Someone "uses it up" right inside the strategy:
def voucher(value): def total(amount): nonlocal value result = max(amount - value, 0) value = 0 return result return totalcheckout = Checkout(voucher(10))print(f"Order preview: €{checkout.total(80):.2f}")print(f"Payment: €{checkout.total(80):.2f}")Order preview: €70.00Payment: €80.00The preview showed €70, and the payment asked for €80. Which line is the bug on?
Pick the line with the bug.
The checkout calls the strategy every time it needs the sum: for the preview, for the invoice, for the payment. The strategy should only compute. Once it changes itself, the second question gets a different answer. Here nonlocal value lets the inner function change value from outside, and that is exactly the bug. Remove the line: the sum is €70 both times. Whether the voucher has been used is recorded in the order, after the payment goes through.
The idea
When you don't need it
Strategy has a cost: a separate function for every variant and a table, instead of one if. It isn't worth it when:
- there are two variants and they don't change. "With VAT or without" is one
if; - the rule is used in one place and nobody touches it;
- there is only one function, and the table is "just in case".
It is worth it when the variants grow, change while the program runs (the customer chooses), or different parts of the program need different rules.
Exercise
Apple Pay without touching the checkout
Solve the task in your editor.
Apple Pay. The shop adds Apple Pay with a 1% fee on the sum. Add a function apple_pay and pay an €80 order with it. The checkout and the other methods are not touched.
Apple Pay: €80.80More tasks
- Free cash on delivery over €100. Write a cash-on-delivery strategy that doesn't charge €2.50 for orders over €100. Run it with €80 and with €120.
- The method from the input. Add bank transfer and installments to the table from the card "Which method the customer chooses". Read the amount from the input too.
- A gift card with a balance. The card has a balance of €50: it pays what it can, and the rest is paid by card. Think about where the new balance is recorded. Does the strategy change it, or the order after the payment?
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Throwing and your own exceptions · dict and set · Lambdas · Design patterns
- Continues in: Observer · Factory Method and Simple Factory · Singleton and Dependency Injection · Adapter and Facade
- Tasks on this topic: Design patterns