
Implementing the Strategy Design Pattern in Python for Flexible Code Architecture
Learn how to implement the Strategy design pattern in Python to make your codebase more flexible, testable, and maintainable. This post walks through core concepts, practical examples using dataclasses, performance gains with caching, and how contextual decorators can enhance strategy behavior — all with clear, line-by-line explanations and best practices.
Introduction
Imagine you’re building a payment system where the payment method could be credit card, PayPal, or cryptocurrency. Would you hard-code conditionals everywhere? Or would you encapsulate each behavior and plug them in when needed?
The Strategy design pattern helps you do the latter. It encapsulates interchangeable algorithms (or behaviors) behind a consistent interface and lets you swap them at runtime. In Python, with its first-class functions, dataclasses, and functional tools, the Strategy pattern becomes both elegant and practical.
In this article you will learn:
- What the Strategy pattern is and why it matters.
- How to implement it idiomatically in Python using classes and functions.
- How to use Python's dataclasses to simplify definitions.
- How to optimize strategy performance with caching (functools.lru_cache).
- How to build contextual decorators to modify behavior dynamically.
- Best practices, pitfalls, and advanced tips.
Core Concepts: Breaking the pattern down
Key ideas behind the Strategy pattern:
- Strategy: an algorithm or behavior encapsulated in a discrete unit (class or function).
- Context: an object that has a reference to a Strategy and delegates work to it.
- Interchangeability: strategies share a common interface so the context can use them uniformly.
- Reduce conditional logic (if/elif) spread across code.
- Make algorithms interchangeable and testable.
- Open for extension: add new strategies without changing the context (Open/Closed principle).
- Sorting algorithms or comparison strategies.
- Pricing or discount calculation in e-commerce.
- Authentication or serialization strategies.
- Pluggable business rules.
- Over-abstraction if used unnecessarily.
- Managing state across strategies (stateful strategies require careful design).
- Balancing readability vs. flexibility.
Prerequisites (Python features we’ll use)
- abc — Abstract Base Classes for strategy interfaces.
- dataclasses — For concise Context and Strategy data containers.
- functools.lru_cache — For caching expensive computations from strategies.
- contextlib — For building contextual decorators (context managers + decorators).
- abc: https://docs.python.org/3/library/abc.html
- dataclasses: https://docs.python.org/3/library/dataclasses.html
- functools.lru_cache: https://docs.python.org/3/library/functools.html#functools.lru_cache
- contextlib: https://docs.python.org/3/library/contextlib.html
Basic implementation: class-based Strategy
Let's start with a simple example: different logging formats as strategies.
from abc import ABC, abstractmethod
from typing import Any
class LogStrategy(ABC):
@abstractmethod
def format(self, level: str, message: str) -> str:
"""Return the formatted log string."""
pass
class JsonLogStrategy(LogStrategy):
def format(self, level: str, message: str) -> str:
import json
return json.dumps({"level": level, "message": message})
class SimpleLogStrategy(LogStrategy):
def format(self, level: str, message: str) -> str:
return f"[{level}] {message}"
class Logger:
def __init__(self, strategy: LogStrategy):
self.strategy = strategy
def log(self, level: str, message: str):
print(self.strategy.format(level, message))
Line-by-line explanation:
from abc import ABC, abstractmethod
: import base classes to define an interface.class LogStrategy(ABC)
: defines an abstract base class for strategies.format(...)
annotated with@abstractmethod
: enforces override by concrete strategies.JsonLogStrategy
andSimpleLogStrategy
: concrete implementations.Logger
is the Context, holding a strategy and delegatinglog
to it.log
prints formatted messages returned by the strategy.
logger = Logger(SimpleLogStrategy())
logger.log("INFO", "Starting up")
logger.strategy = JsonLogStrategy()
logger.log("ERROR", "Something failed")
Inputs/Outputs:
- Input: level string and message string.
- Output: printed formatted messages (plain or JSON).
- Passing an object that doesn't implement
format
will raiseTypeError
at creation time if not enforced; our ABC helps catch missing method implementations.
- Adding a new log format requires adding a new strategy class — no changes to
Logger
.
Function-based strategies (Pythonic style)
Because Python treats functions as first-class values, simple strategies can be functions:
from typing import Callable
LogFunc = Callable[[str, str], str]
def simple_format(level: str, message: str) -> str:
return f"[{level}] {message}"
def json_format(level: str, message: str) -> str:
import json
return json.dumps({"level": level, "message": message})
class FuncLogger:
def __init__(self, formatter: LogFunc):
self.formatter = formatter
def log(self, level: str, message: str):
print(self.formatter(level, message))
Line-by-line:
LogFunc
is a type alias describing the callable signature.simple_format
andjson_format
are functions matching that signature.FuncLogger
accepts any callable with the proper signature.
- No static guarantee of signature — consider type hints and unit tests.
- Less boilerplate for stateless strategies.
- Easy to pass closures capturing external configuration.
Using dataclasses for cleaner Context and Strategies
When strategies or contexts hold data/state, Python's dataclasses simplify definitions and provide built-in methods like __init__, __repr__, and equality.
Example: Payment strategies with dataclasses.
from dataclasses import dataclass
from typing import Protocol
class PaymentStrategy(Protocol):
def pay(self, amount: float) -> str:
...
@dataclass
class CreditCardStrategy:
card_number: str
card_holder: str
def pay(self, amount: float) -> str:
# In production you'd call a payment gateway here
return f"Charged ${amount:.2f} to card {self.card_number[-4:]}"
@dataclass
class PayPalStrategy:
email: str
def pay(self, amount: float) -> str:
return f"Paid ${amount:.2f} via PayPal account {self.email}"
@dataclass
class PaymentProcessor:
strategy: PaymentStrategy
def process(self, amount: float) -> str:
return self.strategy.pay(amount)
Line-by-line:
from dataclasses import dataclass
: import decorator to reduce boilerplate.PaymentStrategy
is a Protocol (PEP 544) — a lightweight interface using structural typing.CreditCardStrategy
andPayPalStrategy
are dataclasses containing state and implementingpay
.PaymentProcessor
is a dataclass holding a strategy and delegatingprocess
.
- Input: amount (float), plus strategy-specific data fields.
- Output: strings simulating payment outcomes.
- Dataclasses are mutable by default. Use
@dataclass(frozen=True)
if you need immutability. - Real systems must handle security (never store card numbers in plaintext).
- Cleaner, concise, and good for classes primarily used to store data.
Optimizing strategy execution with caching
What if a strategy performs expensive computations (e.g., exchange rate conversion, complex pricing)? Use Python's built-in caching (functools.lru_cache) to memoize results.
Example: A pricing strategy that computes discounts based on heavy computation or external API.
from functools import lru_cache
from dataclasses import dataclass
@dataclass
class ExchangeRate:
base: str
target: str
@lru_cache(maxsize=128)
def get_rate(self) -> float:
# Simulate expensive network call
import time
time.sleep(0.5)
# Pretend we fetched a rate (in real-life call an API)
return 1.2345
@dataclass
class CurrencyPriceStrategy:
rate_provider: ExchangeRate
def convert(self, amount: float) -> float:
rate = self.rate_provider.get_rate()
return amount rate
Line-by-line:
@lru_cache(maxsize=128)
caches results ofget_rate()
keyed by self (note: caching instance methods requires careful handling).time.sleep(0.5)
simulates latency of fetching data.convert
uses the cached rate to speed up repeated conversions.
self
identity and method args. This works but can be surprising: if self
is mutable or not hashable, you'll get a TypeError. A common pattern is to cache a function that only depends on immutable parameters (e.g., provider key).
Edge cases and advice:
- For methods, prefer caching at the function level or use a cached property pattern.
- Use
@functools.cached_property
(Python 3.8+) for per-instance caching of computed properties. - For cross-process caching or larger datasets, use external caches (redis, memcached) or the
cachetools
library.
from functools import cached_property
@dataclass
class ExchangeRate:
base: str
target: str
@cached_property
def rate(self) -> float:
import time
time.sleep(0.5)
return 1.2345
Now rate
is computed once per instance and stored.
Creating and using contextual decorators for enhanced functionality
Contextual decorators are a powerful technique when strategy behavior should be enhanced or conditionally modified based on some context (e.g., toggle debug logging, temporarily override strategy).
Example: a decorator that temporarily swaps a strategy on a context for the duration of a function call.
from contextlib import contextmanager
from functools import wraps
from dataclasses import dataclass
@contextmanager
def temporary_strategy(context, new_strategy):
original = context.strategy
context.strategy = new_strategy
try:
yield
finally:
context.strategy = original
def with_temporary_strategy(context, temp_strategy):
def decorator(func):
@wraps(func)
def wrapper(args, *kwargs):
with temporary_strategy(context, temp_strategy):
return func(args, *kwargs)
return wrapper
return decorator
@dataclass
class GreeterStrategy:
def greet(self, name: str) -> str:
return f"Hello, {name}!"
@dataclass
class LoudGreeterStrategy:
def greet(self, name: str) -> str:
return f"HELLO, {name.upper()}!!!"
@dataclass
class Greeter:
strategy: GreeterStrategy
def greet(self, name: str) -> str:
return self.strategy.greet(name)
greeter = Greeter(GreeterStrategy())
@with_temporary_strategy(greeter, LoudGreeterStrategy())
def announce(name):
return greeter.greet(name)
Line-by-line:
temporary_strategy
is a context manager that temporarily assigns a new strategy tocontext.strategy
.with_temporary_strategy
is a decorator factory that wraps a function, runs it within the temporary strategy context, then restores the original.Greeter
uses strategies;announce
uses a loud greeter just for its execution.
announce("Alice")
returns a loudly formatted greeting, whilegreeter.greet("Alice")
remains unaffected outside the decorated call.
- Add logging, metrics, or modify behavior based on environment variables.
- This pattern integrates nicely with dependency injection and testing.
- Ensure thread-safety if shared context across threads — consider thread-local storage (threading.local) instead of mutating shared objects.
Putting it together: Real-world example — Pricing engine
We’ll build a small pricing engine that:
- Uses Strategy pattern for pricing algorithms.
- Uses dataclasses for cleaner data models.
- Caches expensive parts like tax rate lookup.
- Offers a contextual decorator to apply a promotional strategy temporarily.
from dataclasses import dataclass
from functools import lru_cache, wraps
from typing import Protocol
class PricingStrategy(Protocol):
def price(self, base_price: float, qty: int) -> float:
...
@dataclass
class BasePricing:
def price(self, base_price: float, qty: int) -> float:
return base_price qty
@dataclass
class BulkDiscountPricing:
threshold: int
discount: float # fraction, e.g., 0.1 == 10%
def price(self, base_price: float, qty: int) -> float:
total = base_price qty
if qty >= self.threshold:
return total (1 - self.discount)
return total
@dataclass
class TaxedPricing:
country: str
@lru_cache(maxsize=32)
def _tax_rate(self) -> float:
# Simulate expensive lookup by country
rates = {"US": 0.07, "DE": 0.19, "FR": 0.20}
return rates.get(self.country, 0.0)
def price(self, base_price: float, qty: int) -> float:
subtotal = base_price qty
tax = subtotal self._tax_rate()
return subtotal + tax
@dataclass
class PricingContext:
strategy: PricingStrategy
def compute(self, base_price: float, qty: int) -> float:
return self.strategy.price(base_price, qty)
Contextual decorator to apply a temporary promo strategy
def with_promo(context: PricingContext, promo_strategy: PricingStrategy):
def deco(func):
@wraps(func)
def wrapper(args, kwargs):
original = context.strategy
context.strategy = promo_strategy
try:
return func(args, **kwargs)
finally:
context.strategy = original
return wrapper
return deco
Explanation highlights:
PricingStrategy
uses Protocol to define the expected interface.TaxedPricing._tax_rate
is cached so repeated calls for the same country are fast.with_promo
temporarily swapscontext.strategy
, similar to previous example.
- Compute normal price with
BasePricing
. - Apply
BulkDiscountPricing
. - Apply
TaxedPricing
. - Use
with_promo
decorator for a temporary special price (e.g., free shipping or discount).
Best practices
- Use Strategy when you have multiple algorithms and you want to switch them dynamically — avoid premature abstraction.
- Prefer function strategies for stateless behavior and class/dataclass strategies for stateful or complex behavior.
- Use Protocols (typing.Protocol) or ABCs for clear interfaces; Protocols are more flexible (structural typing).
- Cache carefully:
- Keep strategies small, focused, and single-responsibility.
- Favor immutability for strategies where possible:
@dataclass(frozen=True)
reduces bugs. - For concurrency: consider thread-local storage or immutable contexts to avoid race conditions.
- Add unit tests for each strategy and the context behavior.
Common pitfalls
- Overuse: don’t extract strategies prematurely; sometimes a simple if/else is fine.
- Stateful strategies: be cautious when sharing a strategy instance across contexts — unintended state can leak.
- Caching instance methods incorrectly: lru_cache expects hashable args. Use cached_property or cache at a function level.
- Thread-safety: mutating a shared context’s strategy in multi-threaded applications can cause subtle bugs.
Advanced tips
- Composition: combine strategies to build complex behaviors (e.g., a pipeline of strategies).
- Decorators + Strategies: use decorators to add cross-cutting concerns (logging, retry, metrics) to strategies without changing them.
- Dependency injection: use factories or DI containers to configure strategies in production.
- Serialization: if strategies need persistence, ensure they're serializable (store config, not the callable).
- Plugin systems: load strategies dynamically using entry points (setuptools) or importlib.
Conclusion
The Strategy design pattern is a practical technique for making your Python code base flexible and maintainable. Python's features — first-class functions, dataclasses, caching utilities, and context managers — make it straightforward to implement robust and performant strategies. Whether you’re swapping logging formats, payment processors, or pricing algorithms, strategies give you a clean separation of concerns.
Try this now:
- Identify a place in your code with multiple conditionals and refactor one branch into a strategy.
- Convert a data-heavy class to a dataclass and see the simplification.
- Add caching (lru_cache or cached_property) to an expensive strategy and measure the speedup.
- Experiment with a contextual decorator to temporarily change behaviors for testing or feature flags.
Further reading and references
- Design Patterns: Elements of Reusable Object-Oriented Software — classic patterns book (Gang of Four).
- Python docs: abc — https://docs.python.org/3/library/abc.html
- Python docs: dataclasses — https://docs.python.org/3/library/dataclasses.html
- Python docs: functools.lru_cache — https://docs.python.org/3/library/functools.html#functools.lru_cache
- PEP 544: Structural subtyping (Protocols) — https://peps.python.org/pep-0544/
Was this article helpful?
Your feedback helps us improve our content. Thank you!