Strategy
Strategy puts each interchangeable algorithm behind one interface so the code that uses it can be handed any of them, even switch at runtime. This page turns Pedal's pricing plans into strategies, adds peak pricing without editing anything, and builds a cheapest-plan button in one line.
1. The pain
Pedal prices a ride differently for each plan: members ride 30 minutes free then pay 10p a minute; pay-as-you-go riders pay £1 to unlock plus 10p a minute; students pay half the pay-as-you-go price. The first charge() function held all three in an if plan == … chain, next to the code that loads the ride and sends the receipt.
Marketing now wants peak pricing for pay-as-you-go at rush hour, and a "show me the cheapest plan for this ride" button. Each new plan grows the chain inside a function that also does other jobs, so every change means retesting all of it, and nothing else can reuse one plan's rule on its own.
2. The idea in one picture
Strategy puts each interchangeable algorithm in its own object behind one interface, and lets the code that uses it (the context) hold whichever one it's given. Billing asks its Pricing for a price; it doesn't know or care which plan it holds. Plans can be swapped at runtime, compared side by side, and added without editing the others.
In Python and TypeScript a strategy is often just a function, and a dictionary of functions is a perfectly good strategy registry. Use classes when a strategy needs a name, configuration or several methods.
3. The roles
| Role | Its job | In Pedal |
|---|---|---|
| Strategy | The interface every algorithm implements. | Pricing.price(minutes) |
| Concrete strategy | One algorithm. | Member, PayAsYouGo, Student, PeakPayAsYouGo |
| Context | Holds a strategy and delegates the varying step to it. | Billing |
| Client | Chooses the strategy and hands it to the context. | Checkout, from the rider's plan |
4. Before and after
Ride pricing both ways (Python sketch; the full program is in section 5).
Without the pattern
def charge(rider, minutes):
ride = load_ride(rider)
if rider.plan == "member":
p = max(0, minutes - 30) * 10
elif rider.plan == "payg":
p = 100 + minutes * 10
elif rider.plan == "student":
p = (100 + minutes * 10) // 2
# peak pricing goes here too...
send_receipt(rider, p)With it
class Member(Pricing):
def price(self, minutes):
return max(0, minutes - 30) * 10
class Billing:
def __init__(self, pricing):
self.pricing = pricing
def charge(self, rider, minutes):
p = self.pricing.price(minutes)
send_receipt(rider, p)| The next change | Without the pattern | With it |
|---|---|---|
| Add peak pricing | A fourth branch in charge(); retest all of it | A PeakPayAsYouGo class; nothing else changes |
| "Show me the cheapest plan" | Call charge() with fake riders, or copy the rules | Call price() on each strategy and compare |
| A rider switches plan mid-month | Change a field and hope every branch reads it | billing.switch(Member()) |
Each rule is a small, separately testable object, and the code that uses it never changes when a rule is added.
5. The code
Four pricing strategies behind one interface and a Billing context that holds one. Leo switches plan on the same object; the last line compares every plan for a 45-minute ride.
from __future__ import annotations
from abc import ABC, abstractmethod
# ---- The strategy interface: one way to price a ride. ----
class Pricing(ABC):
name = ""
@abstractmethod
def price(self, minutes: int) -> int: ...
class Member(Pricing):
name = "member"
def price(self, minutes: int) -> int:
return max(0, minutes - 30) * 10
class PayAsYouGo(Pricing):
name = "pay-as-you-go"
def price(self, minutes: int) -> int:
return 100 + minutes * 10
class Student(Pricing):
name = "student"
def price(self, minutes: int) -> int:
return PayAsYouGo().price(minutes) // 2
class PeakPayAsYouGo(Pricing): # added later: no other class was edited
name = "peak pay-as-you-go"
def price(self, minutes: int) -> int:
return 100 + minutes * 15
# ---- The context: uses whichever strategy it holds, and can be given another. ----
class Billing:
def __init__(self, pricing: Pricing) -> None:
self.pricing = pricing
def switch(self, pricing: Pricing) -> None:
self.pricing = pricing
def charge(self, rider: str, minutes: int) -> str:
return f"{rider} ({self.pricing.name}), {minutes} min: {self.pricing.price(minutes)}p"
if __name__ == "__main__":
print(Billing(Member()).charge("mia", 42))
leo = Billing(PayAsYouGo())
print(leo.charge("leo", 18))
print(Billing(Student()).charge("sam", 18))
leo.switch(Member()) # same object, new behaviour, chosen at runtime
print(leo.charge("leo", 18))
print(Billing(PeakPayAsYouGo()).charge("ana", 18))
cheapest = min([Member(), PayAsYouGo(), Student()], key=lambda p: p.price(45))
print(f"cheapest for a 45-minute ride: {cheapest.name} ({cheapest.price(45)}p)")
// ---- The strategy interface: one way to price a ride. ----
interface Pricing {
readonly name: string;
price(minutes: number): number;
}
class Member implements Pricing {
readonly name = "member";
price(minutes: number) { return Math.max(0, minutes - 30) * 10; }
}
class PayAsYouGo implements Pricing {
readonly name = "pay-as-you-go";
price(minutes: number) { return 100 + minutes * 10; }
}
class Student implements Pricing {
readonly name = "student";
price(minutes: number) { return Math.floor(new PayAsYouGo().price(minutes) / 2); }
}
class PeakPayAsYouGo implements Pricing { // added later: no other class was edited
readonly name = "peak pay-as-you-go";
price(minutes: number) { return 100 + minutes * 15; }
}
// ---- The context: uses whichever strategy it holds, and can be given another. ----
class Billing {
constructor(private pricing: Pricing) {}
switch(pricing: Pricing): void { this.pricing = pricing; }
charge(rider: string, minutes: number): string {
return `${rider} (${this.pricing.name}), ${minutes} min: ${this.pricing.price(minutes)}p`;
}
}
console.log(new Billing(new Member()).charge("mia", 42));
const leo = new Billing(new PayAsYouGo());
console.log(leo.charge("leo", 18));
console.log(new Billing(new Student()).charge("sam", 18));
leo.switch(new Member()); // same object, new behaviour, chosen at runtime
console.log(leo.charge("leo", 18));
console.log(new Billing(new PeakPayAsYouGo()).charge("ana", 18));
const plans: Pricing[] = [new Member(), new PayAsYouGo(), new Student()];
const cheapest = plans.reduce((best, p) => (p.price(45) < best.price(45) ? p : best));
console.log(`cheapest for a 45-minute ride: ${cheapest.name} (${cheapest.price(45)}p)`);import java.util.Comparator;
import java.util.List;
public class Main {
// ---- The strategy interface: one way to price a ride. ----
interface Pricing {
String name();
int price(int minutes);
}
static class Member implements Pricing {
public String name() { return "member"; }
public int price(int minutes) { return Math.max(0, minutes - 30) * 10; }
}
static class PayAsYouGo implements Pricing {
public String name() { return "pay-as-you-go"; }
public int price(int minutes) { return 100 + minutes * 10; }
}
static class Student implements Pricing {
public String name() { return "student"; }
public int price(int minutes) { return new PayAsYouGo().price(minutes) / 2; }
}
static class PeakPayAsYouGo implements Pricing { // added later: no other class was edited
public String name() { return "peak pay-as-you-go"; }
public int price(int minutes) { return 100 + minutes * 15; }
}
// ---- The context: uses whichever strategy it holds, and can be given another. ----
static class Billing {
private Pricing pricing;
Billing(Pricing pricing) { this.pricing = pricing; }
void switchTo(Pricing pricing) { this.pricing = pricing; }
String charge(String rider, int minutes) {
return rider + " (" + pricing.name() + "), " + minutes + " min: " + pricing.price(minutes) + "p";
}
}
public static void main(String[] args) {
System.out.println(new Billing(new Member()).charge("mia", 42));
Billing leo = new Billing(new PayAsYouGo());
System.out.println(leo.charge("leo", 18));
System.out.println(new Billing(new Student()).charge("sam", 18));
leo.switchTo(new Member()); // same object, new behaviour, chosen at runtime
System.out.println(leo.charge("leo", 18));
System.out.println(new Billing(new PeakPayAsYouGo()).charge("ana", 18));
Pricing cheapest = List.of(new Member(), new PayAsYouGo(), new Student()).stream()
.min(Comparator.comparingInt(p -> p.price(45))).orElseThrow();
System.out.println("cheapest for a 45-minute ride: " + cheapest.name() + " (" + cheapest.price(45) + "p)");
}
}Output (the same in all three languages)
mia (member), 42 min: 120p leo (pay-as-you-go), 18 min: 280p sam (student), 18 min: 140p leo (member), 18 min: 0p ana (peak pay-as-you-go), 18 min: 370p cheapest for a 45-minute ride: member (150p)
6. Try it
Compare plans for one ride
Because every plan has the same interface, the app can price one ride under all of them and pick the cheapest.
7. When not to use it
What you get
- New algorithms without edits: a class or function per rule.
- Runtime choice: swap the strategy on the same context.
- Small, testable rules: each one tested on its own.
- Comparable: run them side by side, as the cheapest-plan button does.
What you pay
- More objects: one per algorithm.
- Clients must choose: someone has to know which strategy fits.
- A shared interface to agree: if one rule needs extra data, every strategy's signature feels it.
- Overkill for one fixed rule: a branch or two is simpler.
| If… | Use instead |
|---|---|
| The behaviour changes because the object's state changes | 14 State |
| Only some steps of a fixed sequence vary | 15 Template method |
| Two whole families vary independently | 11 Bridge |
| There are two rules and no more coming | An if/else is fine |
8. How it goes wrong
Member.price() reaches into Billing for data, the two are coupled again. Pass what it needs.Billing picks its own strategy with an if/elif, the chain has only moved. Let the caller or a factory choose.9. Remember it and say it
How this hook works: what “Same trip, choose the route” means
What the slogan means, phrase by phrase
- “Same trip”
- The context:
Billing. - “choose”
- The client picks, and can switch.
- “the route”
- The strategy: one pricing rule.
Why this picture fits: A sat-nav keeps everything the same except how it chooses the way, and lets you change that mid-journey.
The story → the concept
| The sat-nav | The code |
|---|---|
| The device | Billing |
| Fastest, shortest, no tolls | Member, PayAsYouGo, Student |
| Changing the setting mid-trip | switch(Member()) |
| A new "scenic" mode | PeakPayAsYouGo |
Watch it on an example
- Leo's billing holds pay-as-you-go: 18 minutes costs 280p.
- He joins as a member;
switch(Member()). - His next 18-minute ride costs 0p.
Billingdidn't change.
The trap it saves you from
An if/elif chain of rules buried inside a function that does other work.
It still works when…
Yes: sort comparators, compression levels, retry policies and payment methods are all strategies.
In an interview or code review
- Name what varies: "The pricing rule varies by plan, so it's a
Pricingstrategy thatBillingholds." - Say who chooses: checkout picks from the rider's plan;
Billingnever checks the plan itself. - Mention the cheap form: in Python, a dict of functions keyed by plan name is a strategy too.
- Contrast with state: a strategy is chosen from outside; a state replaces itself as events arrive.
10. Check yourself
What does a strategy encapsulate?
Adding peak pricing with strategy changes…
Who should choose the strategy?
In Python, what's the lightest strategy?
Strategy vs state: which changes itself as events happen?
11. Go deeper
| Resource | Why |
|---|---|
| 14 State | Same structure, different trigger. |
| Low-level design 03b: Open-closed principle | Pricing plans are the classic open-closed example. |
| Python sorting: key functions | A strategy passed as a function. |
| Wikipedia: Strategy pattern | Structure and history. |