Skip to content
wannacode

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}")
Output
Card: €80.00
Cash on delivery: €82.50
Voucher: €70.00
Bank transfer: €78.40
Installments: €84.00

It 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

Three methods, each in its own function. The voucher is a function that returns a function: 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)
The checkout keeps the function in the field 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_delivery
print(f"Cash on delivery: €{checkout.total(80):.2f}")
checkout.method = voucher(10)
print(f"Voucher: €{checkout.total(80):.2f}")
Output
Card: €80.00
Cash on delivery: €82.50
Voucher: €70.00

checkout = Checkout(card)
print(f"Card: €{checkout.total(80):.2f}")
checkout.method = cash_on_delivery
print(f"Cash on delivery: €{checkout.total(80):.2f}")
checkout.method = voucher(10)
print(f"Voucher: €{checkout.total(80):.2f}")
Step by step, in words
  1. The checkout gets a card: its method field points to the function card.
  2. total(80): the checkout doesn't know the method. It just asks the strategy for the sum and gets 80.00 back.
  3. A new method. Assigning checkout.method points the field to the function cash_on_delivery. Not a single line of Checkout changes.
  4. The same total(80), a different answer: 82.50.
  5. voucher(10) returns a function that remembers value = 10. Nothing points to the old functions any more.
  6. 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.05
checkout = Checkout(bank_transfer)
print(f"Bank transfer: €{checkout.total(80):.2f}")
checkout.method = installments
print(f"Installments: €{checkout.total(80):.2f}")
Output
Bank transfer: €78.40
Installments: €84.00

Not 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}")
Input
cash on delivery
Output
cash on delivery: €82.50

An unknown method never reaches the checkout:

Input
PayPal
Output
No such payment method: PayPal

The 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_delivery
print(f"€{checkout.total(8):.2f}")
Answer

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 total
checkout = Checkout(voucher(10))
print(f"Order preview: €{checkout.total(80):.2f}")
print(f"Payment: €{checkout.total(80):.2f}")
Output
Order preview: €70.00
Payment: €80.00

The preview showed €70, and the payment asked for €80. Which line is the bug on?

Pick the line with the bug.

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.

Output
Apple Pay: €80.80

More 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

1. The shop adds Apple Pay. What do you write with Strategy?
2. What does the checkout keep with Strategy?
3. The voucher function sets its value to zero every time it is called. The checkout asks once for the preview and once for the payment. What happens?
4. When don't you need Strategy?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·