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:
function total(method, amount) { if (method === "card") { return amount; } else if (method === "cash on delivery") { return amount + 2.5; } else if (method === "voucher") { return Math.max(amount - 10, 0); } else if (method === "bank transfer") { return amount * 0.98; } else if (method === "installments") { return amount * 1.05; } throw new Error("Unknown payment method: " + method);}console.log(`Card: €${total("card", 80).toFixed(2)}`);console.log(`Cash on delivery: €${total("cash on delivery", 80).toFixed(2)}`);console.log(`Voucher: €${total("voucher", 80).toFixed(2)}`);console.log(`Bank transfer: €${total("bank transfer", 80).toFixed(2)}`);console.log(`Installments: €${total("installments", 80).toFixed(2)}`);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:function card(amount) { return amount;}
function cashOnDelivery(amount) { return amount + 2.5;}
function voucher(value) { return (amount) => Math.max(amount - value, 0);}method and calls it. Assigning checkout.method changes it:class Checkout { constructor(method) { this.method = method; }
total(amount) { return Math.round(this.method(amount) * 100) / 100; }}const checkout = new Checkout(card);console.log(`Card: €${checkout.total(80).toFixed(2)}`);
checkout.method = cashOnDelivery;console.log(`Cash on delivery: €${checkout.total(80).toFixed(2)}`);
checkout.method = voucher(10);console.log(`Voucher: €${checkout.total(80).toFixed(2)}`);Card: €80.00Cash on delivery: €82.50Voucher: €70.00const checkout = new Checkout(card);console.log(`Card: €${checkout.total(80).toFixed(2)}`);
checkout.method = cashOnDelivery;console.log(`Cash on delivery: €${checkout.total(80).toFixed(2)}`);
checkout.method = voucher(10);console.log(`Voucher: €${checkout.total(80).toFixed(2)}`);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 cashOnDelivery. 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:
function bankTransfer(amount) { return amount * 0.98;}
function installments(amount) { return amount * 1.05;}const checkout = new Checkout(bankTransfer);console.log(`Bank transfer: €${checkout.total(80).toFixed(2)}`);
checkout.method = installments;console.log(`Installments: €${checkout.total(80).toFixed(2)}`);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.
const methods = new Map([ ["card", card], ["cash on delivery", cashOnDelivery], ["voucher", voucher(10)],]);
const choice = await ask("");rl.close();const method = methods.get(choice);if (method === undefined) { console.log("No such payment method: " + choice);} else { const checkout = new Checkout(method); console.log(`${choice}: €${checkout.total(80).toFixed(2)}`);}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 JavaScript 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?
const checkout = new Checkout(voucher(10));console.log(`€${checkout.total(8).toFixed(2)}`);
checkout.method = cashOnDelivery;console.log(`€${checkout.total(8).toFixed(2)}`);Try it
Find the bug
A voucher should be used once. Someone "uses it up" right inside the strategy:
function voucher(value) { return (amount) => { const result = Math.max(amount - value, 0); value = 0; return result; };}const checkout = new Checkout(voucher(10));console.log(`Order preview: €${checkout.total(80).toFixed(2)}`);console.log(`Payment: €${checkout.total(80).toFixed(2)}`);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. 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 applePay 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 · Map and Set · Arrow functions · Design patterns
- Continues in: Observer · Factory Method and Simple Factory · Singleton and Dependency Injection · Adapter and Facade
- Tasks on this topic: Design patterns