Strategy Pattern¶
The Strategy Pattern lets you define a family of algorithms, encapsulate each one, and make them interchangeable at runtime. In Python, first-class functions replace the strategy classes required in traditional OOP implementations.
Mental Model
Instead of hard-coding an algorithm inside a class, pass it in as a function. The class becomes a shell that delegates the "how" to whatever function it receives, so you can swap algorithms at runtime without touching the class itself. In Python, first-class functions make this pattern almost invisible -- no strategy interface needed.
Key Insight: "Encapsulate what varies" — the algorithm varies, so pass it as a function parameter. The context class remains algorithm-agnostic.
Core Structure¶
```python class Context: def init(self, ..., strategy=None): self.strategy = strategy # A function reference
def do_something(self):
if self.strategy:
result = self.strategy(self) # Call the strategy function
return result
def strategy_a(context): # Algorithm A return result
def strategy_b(context): # Algorithm B return result
Usage — swap strategies without touching Context¶
context = Context(..., strategy=strategy_a) ```
Example: E-Commerce Discount System¶
Data Structures¶
```python from decimal import Decimal from typing import Callable, Optional, Sequence from dataclasses import dataclass from typing import NamedTuple
class Customer(NamedTuple): name: str fidelity: int # loyalty points
class LineItem(NamedTuple): product: str quantity: int price: Decimal
def total(self):
return self.price * self.quantity
@dataclass(frozen=True) class Order: customer: Customer cart: Sequence[LineItem] promotion: Optional[Callable[['Order'], Decimal]] = None # ← strategy function
def total(self) -> Decimal:
return sum((item.total() for item in self.cart), start=Decimal(0))
def due(self) -> Decimal:
discount = self.promotion(self) if self.promotion else Decimal(0)
return self.total() - discount
def __repr__(self):
return f'<Order total: {self.total():.2f} due: {self.due():.2f}>'
```
The Order class accepts any callable as promotion. It doesn't know or care which strategy is used — it just calls it.
Strategy Functions¶
```python def fidelity_promo(order: Order) -> Decimal: """5% discount for customers with 1000+ fidelity points.""" if order.customer.fidelity >= 1000: return order.total() * Decimal('0.05') return Decimal(0)
def bulk_item_promo(order: Order) -> Decimal: """10% discount on any line item with 20+ units.""" discount = Decimal(0) for item in order.cart: if item.quantity >= 20: discount += item.total() * Decimal('0.1') return discount
def large_order_promo(order: Order) -> Decimal: """7% discount for orders with 10+ distinct products.""" distinct_items = {item.product for item in order.cart} if len(distinct_items) >= 10: return order.total() * Decimal('0.07') return Decimal(0)
def new_customer_promo(order: Order) -> Decimal: """2% welcome discount for customers with fewer than 50 fidelity points.""" if order.customer.fidelity < 50: return order.total() * Decimal('0.02') return Decimal(0) ```
Using Strategies¶
```python joe = Customer('John Doe', 0) # no fidelity points ann = Customer('Ann Smith', 1100) # high fidelity
cart = [ LineItem('banana', 4, Decimal('.5')), LineItem('apple', 10, Decimal('1.5')), LineItem('watermelon', 5, Decimal(5)), ]
No discount¶
Order(joe, cart)
¶
fidelity_promo — Joe has 0 points, no discount¶
Order(joe, cart, promotion=fidelity_promo)
¶
fidelity_promo — Ann has 1100 points, 5% discount¶
Order(ann, cart, promotion=fidelity_promo)
¶
bulk_item_promo — 30 bananas qualifies¶
banana_cart = [LineItem('banana', 30, Decimal('.5')), LineItem('apple', 10, Decimal('1.5'))] Order(joe, banana_cart, promotion=bulk_item_promo)
¶
```
Adding New Strategies¶
No modifications to Order needed — just write a new function:
```python def flash_sale_promo(order: Order) -> Decimal: """15% discount on all orders during flash sale period.""" return order.total() * Decimal('0.15')
Plug it straight in¶
Order(joe, cart, promotion=flash_sale_promo) ```
This satisfies the Open/Closed Principle: open for extension, closed for modification.
Dynamic Strategy Selection¶
Because strategies are plain functions, they can be stored in lists and selected programmatically:
```python promos = [fidelity_promo, bulk_item_promo, large_order_promo]
def best_promo(order: Order) -> Decimal: """Apply whichever promotion gives the largest discount.""" return max(promo(order) for promo in promos)
Order(ann, cart, promotion=best_promo) ```
Python vs Traditional OOP¶
=== "Pythonic (functions)"
```python
def fidelity_promo(order):
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')
return Decimal(0)
order = Order(customer, cart, promotion=fidelity_promo)
```
=== "Traditional OOP (classes)"
```python
class Promotion:
def discount(self, order): ...
class FidelityPromo(Promotion):
def discount(self, order):
if order.customer.fidelity >= 1000:
return order.total() * Decimal('0.05')
return Decimal(0)
order = Order(customer, cart, strategy=FidelityPromo())
```
The function-based approach is shorter, requires no inheritance hierarchy, and is easier to test each strategy in isolation.
When to Use the Strategy Pattern¶
Use it when:
- You have multiple interchangeable algorithms for the same task
- You want to swap algorithms at runtime
- You want to eliminate large
if/elif/elsechains - New strategies should be addable without modifying existing code
Common applications:
| Domain | Strategies |
|---|---|
| E-commerce | Discount calculations, shipping methods |
| Payments | Credit card, PayPal, cryptocurrency |
| Sorting | Bubble sort, quicksort, merge sort |
| Rendering | HTML, PDF, JSON output |
| Compression | ZIP, GZIP, BZIP2 |
| Authentication | Password, OAuth, SAML |
| Caching | LRU, FIFO, LFU eviction |
Skip it when:
- You only have one or two algorithms that never change
- The algorithms are trivial one-liners
- Client code genuinely needs algorithm-specific knowledge
Summary¶
| Concept | Description |
|---|---|
| Context | The class that delegates to a strategy (Order) |
| Strategy | A callable that implements one algorithm (fidelity_promo) |
| Selection | Pass strategy as a constructor or method argument |
| Extension | Add new strategies as new functions — no class changes |
| Python advantage | First-class functions eliminate the need for strategy classes |
The one-line version: pass a function where behaviour varies; keep everything else the same.
Exercises¶
Exercise 1.
Implement a TextProcessor class that accepts a strategy function in its constructor. The strategy should transform a string. Create three strategies: uppercase_strategy, snake_case_strategy (replaces spaces with underscores and lowercases), and title_strategy. Demonstrate switching strategies at runtime.
Solution to Exercise 1
class TextProcessor:
def __init__(self, strategy):
self.strategy = strategy
def process(self, text):
return self.strategy(text)
def uppercase_strategy(text):
return text.upper()
def snake_case_strategy(text):
return text.lower().replace(" ", "_")
def title_strategy(text):
return text.title()
processor = TextProcessor(uppercase_strategy)
print(processor.process("hello world")) # HELLO WORLD
processor.strategy = snake_case_strategy
print(processor.process("Hello World")) # hello_world
processor.strategy = title_strategy
print(processor.process("hello world")) # Hello World
Exercise 2.
Build a Sorter class that accepts a key_strategy function. Create strategies for sorting a list of (name, score) tuples by name alphabetically, by score ascending, and by score descending. Use the same Sorter instance and swap strategies between sorts.
Solution to Exercise 2
class Sorter:
def __init__(self, key_strategy):
self.key_strategy = key_strategy
def sort(self, items):
return sorted(items, key=self.key_strategy)
students = [("Alice", 88), ("Bob", 95), ("Charlie", 72)]
by_name = lambda item: item[0]
by_score_asc = lambda item: item[1]
by_score_desc = lambda item: -item[1]
sorter = Sorter(by_name)
print(sorter.sort(students))
sorter.key_strategy = by_score_asc
print(sorter.sort(students))
sorter.key_strategy = by_score_desc
print(sorter.sort(students))
Exercise 3.
Create a shipping cost calculator that uses the strategy pattern. Write a calculate_shipping(weight, strategy) function and three strategy functions: standard_shipping (flat rate plus per-kg cost), express_shipping (rate per kg with a minimum charge), and free_shipping_over_50 (free if the computed cost exceeds a threshold, else standard rate). Demonstrate each.
Solution to Exercise 3
def standard_shipping(weight):
return 5.0 + weight * 0.5
def express_shipping(weight):
return max(10.0, weight * 2.0)
def free_shipping_over_50(weight):
cost = standard_shipping(weight)
return 0.0 if cost > 50.0 else cost
def calculate_shipping(weight, strategy):
return strategy(weight)
print(f"Standard: ${calculate_shipping(10, standard_shipping):.2f}")
print(f"Express: ${calculate_shipping(10, express_shipping):.2f}")
print(f"Free>50: ${calculate_shipping(10, free_shipping_over_50):.2f}")
print(f"Free>50: ${calculate_shipping(100, free_shipping_over_50):.2f}")