Have you ever found yourself trapped in a labyrinth of if-elif-else statements? You start with a simple function, but as new requirements roll in, it grows into a monolithic beast that’s terrifying to modify and impossible to test. This common scenario is a direct violation of core software design principles, leading to brittle, unmaintainable code.
Enter the Strategy Pattern, a behavioral design pattern that offers an elegant escape. It allows you to define a family of algorithms, encapsulate each one in a separate class, and make them interchangeable. Instead of a rigid structure, you get a flexible system where you can select the right algorithm at runtime.
Think of it like a professional golfer. Depending on the terrain, distance, and wind, they choose a specific club (a driver, an iron, a putter) to make the shot. The goal is the same—get the ball in the hole—but the strategy (the club) changes with the context. The Strategy Pattern brings this same flexibility to your code.
The Problem: The Unmaintainable Salary Calculator
Let’s ground this in a practical example. Imagine we’re building a payroll system. The company has different types of employees—full-time, hourly, freelancers—and each has a unique salary calculation formula.
A naive approach might look like this:
from dataclasses import dataclass
from enum import Enum, auto
class EmployeeType(Enum):
FULL_TIME = auto()
HOURLY = auto()
FREELANCE = auto()
INTERN = auto()
@dataclass
class Employee:
name: str
type: EmployeeType
base_salary: int = 0
bonus: int = 0
hours_worked: int = 0
hourly_rate: int = 0
gross_amount: int = 0
def calculate_salary(employee: Employee) -> float:
"""Calculates salary based on employee type using conditional logic."""
if employee.type == EmployeeType.FULL_TIME:
return employee.base_salary + employee.bonus
elif employee.type == EmployeeType.HOURLY:
return employee.hours_worked * employee.hourly_rate
elif employee.type == EmployeeType.FREELANCE:
# Freelancers have taxes and commissions deducted
tax = employee.gross_amount * 0.10
commission = employee.gross_amount * 0.05
return employee.gross_amount - tax - commission
elif employee.type == EmployeeType.INTERN:
return 1000.0 # Fixed stipend
else:
raise ValueError(f"Unknown employee type: {employee.type}")
# --- Usage ---
full_time_emp = Employee(name="Ahmed", type=EmployeeType.FULL_TIME, base_salary=50000, bonus=10000)
freelance_emp = Employee(name="Maria", type=EmployeeType.FREELANCE, gross_amount=70000)
print(f"{full_time_emp.name}'s Salary: ${calculate_salary(full_time_emp)}")
print(f"{freelance_emp.name}'s Salary: ${calculate_salary(freelance_emp)}")
[!WARNING] This
calculate_salaryfunction is a ticking time bomb.
- Violates Single Responsibility Principle (SRP): The function is responsible for all calculation algorithms.
- Violates Open/Closed Principle (OCP): To add a new employee type (e.g., “Contractor”), you must modify this function, risking the introduction of bugs into existing logic.
- Hard to Test: You need to write complex tests to cover every single branch of this function.
The Solution: Refactoring with the Strategy Pattern
Let’s dismantle this monolith and rebuild it using the Strategy Pattern. We’ll organize our code into a clean, maintainable structure.
First, let’s visualize the new structure we’re aiming for.
payroll_system/
├── main.py
├── employee.py
└── strategies/
├── __init__.py
├── interface.py
├── full_time.py
├── hourly.py
├── freelance.py
└── intern.py
Step 1: Define the Strategy Interface
The “interface” is a contract that all our concrete strategies must follow. In Python, we define this using an Abstract Base Class (ABC). It ensures every strategy has the same method signature.
# strategies/interface.py
from abc import ABC, abstractmethod
from employee import Employee # Assuming employee.py contains the Employee class
class SalaryStrategy(ABC):
"""The Strategy Interface for calculating salary."""
@abstractmethod
def calculate(self, employee: Employee) -> float:
"""Calculate the salary for a given employee."""
pass
Step 2: Implement Concrete Strategies
Now, we encapsulate each if block’s logic into its own class. Each class inherits from our SalaryStrategy and provides a concrete implementation for the calculate method.
Full-Time Strategy:
# strategies/full_time.py
from .interface import SalaryStrategy
from employee import Employee
class FullTimeStrategy(SalaryStrategy):
def calculate(self, employee: Employee) -> float:
return employee.base_salary + employee.bonus
Hourly Strategy:
# strategies/hourly.py
from .interface import SalaryStrategy
from employee import Employee
class HourlyStrategy(SalaryStrategy):
def calculate(self, employee: Employee) -> float:
return employee.hours_worked * employee.hourly_rate
Freelance Strategy:
# strategies/freelance.py
from .interface import SalaryStrategy
from employee import Employee
class FreelanceStrategy(SalaryStrategy):
def calculate(self, employee: Employee) -> float:
tax = employee.gross_amount * 0.10
commission = employee.gross_amount * 0.05
return employee.gross_amount - tax - commission
Intern Strategy:
# strategies/intern.py
from .interface import SalaryStrategy
from employee import Employee
class InternStrategy(SalaryStrategy):
def calculate(self, employee: Employee) -> float:
return 1000.0
Step 3: Define the Context
The “Context” is the object that uses a strategy. It doesn’t know the details of any specific algorithm; it just knows how to use the strategy object it’s given. This decouples the context from the concrete implementations.
# main.py (or a new payroll_context.py)
from employee import Employee
from strategies.interface import SalaryStrategy
class PayrollContext:
"""The Context that uses a salary calculation strategy."""
def __init__(self, strategy: SalaryStrategy):
self._strategy = strategy
def set_strategy(self, strategy: SalaryStrategy):
"""Allows changing the strategy at runtime."""
self._strategy = strategy
def calculate_salary(self, employee: Employee) -> float:
"""Delegates the calculation to the current strategy object."""
return self._strategy.calculate(employee)
This class diagram illustrates the relationships we’ve just built:
classDiagram
class PayrollContext {
-SalaryStrategy strategy
+set_strategy(strategy)
+calculate_salary(employee)
}
class SalaryStrategy {
<<interface>>
+calculate(employee)*
}
class FullTimeStrategy {
+calculate(employee)
}
class HourlyStrategy {
+calculate(employee)
}
class FreelanceStrategy {
+calculate(employee)
}
PayrollContext o-- SalaryStrategy
SalaryStrategy <|-- FullTimeStrategy
SalaryStrategy <|-- HourlyStrategy
SalaryStrategy <|-- FreelanceStrategy
Step 4: Putting It All Together
Now, our client code is much cleaner. Instead of a giant if block, we simply choose the right strategy and pass it to our context.
Here’s how the logic evolves, shown as a diff:
- def calculate_salary(employee: Employee) -> float:
- """Calculates salary based on employee type using conditional logic."""
- if employee.type == EmployeeType.FULL_TIME:
- return employee.base_salary + employee.bonus
- elif employee.type == EmployeeType.HOURLY:
- return employee.hours_worked * employee.hourly_rate
- elif employee.type == EmployeeType.FREELANCE:
- tax = employee.gross_amount * 0.10
- commission = employee.gross_amount * 0.05
- return employee.gross_amount - tax - commission
- elif employee.type == EmployeeType.INTERN:
- return 1000.0
- else:
- raise ValueError(f"Unknown employee type: {employee.type}")
+ # --- New, clean approach ---
+ from strategies import FullTimeStrategy, FreelanceStrategy
+
+ full_time_emp = Employee(name="Ahmed", type=EmployeeType.FULL_TIME, base_salary=50000, bonus=10000)
+ freelance_emp = Employee(name="Maria", type=EmployeeType.FREELANCE, gross_amount=70000)
+
+ # Calculate for Ahmed
+ context = PayrollContext(FullTimeStrategy())
+ ahmed_salary = context.calculate_salary(full_time_emp)
+ print(f"{full_time_emp.name}'s Salary: ${ahmed_salary}")
+
+ # Calculate for Maria by changing the strategy
+ context.set_strategy(FreelanceStrategy())
+ maria_salary = context.calculate_salary(freelance_emp)
+ print(f"{freelance_emp.name}'s Salary: ${maria_salary}")
Dynamic Strategy Selection with a Factory
Manually creating strategy instances in the client code is good, but we can do even better. We can hide the creation logic behind a Factory. A factory is a function or class that’s responsible for creating objects for us. This further decouples the client, which no longer needs to know about the concrete strategy classes at all.
A dictionary is a very Pythonic way to implement a simple factory.
# strategies/__init__.py
from .interface import SalaryStrategy
from .full_time import FullTimeStrategy
from .hourly import HourlyStrategy
from .freelance import FreelanceStrategy
from .intern import InternStrategy
from employee import EmployeeType
# The Factory: A simple dictionary mapping types to strategy classes
STRATEGY_MAP = {
EmployeeType.FULL_TIME: FullTimeStrategy,
EmployeeType.HOURLY: HourlyStrategy,
EmployeeType.FREELANCE: FreelanceStrategy,
EmployeeType.INTERN: InternStrategy,
}
def get_strategy(employee_type: EmployeeType) -> SalaryStrategy:
"""Factory function to get the correct strategy instance."""
strategy_class = STRATEGY_MAP.get(employee_type)
if not strategy_class:
raise ValueError(f"No strategy found for type: {employee_type}")
return strategy_class()
Now, the client code becomes incredibly simple and robust:
# main.py
from employee import Employee, EmployeeType
from strategies import get_strategy
from payroll_context import PayrollContext # Assuming PayrollContext is in its own file
# --- Usage ---
employee = Employee(name="Mohammed", type=EmployeeType.HOURLY, hours_worked=100, hourly_rate=30)
# 1. Get the strategy from the factory
strategy = get_strategy(employee.type)
# 2. Use the context to calculate
context = PayrollContext(strategy)
salary = context.calculate_salary(employee)
print(f"{employee.name}'s Salary: ${salary}")
The flow is now beautifully decoupled:
graph TD
A[Client Code] --> B{get_strategy(employee.type)};
B --> C{Strategy Factory};
C -- Selects based on type --> D[Instantiate Correct Strategy];
D --> A;
A --> E[PayrollContext];
E -- Executes --> F[Strategy.calculate()];
F --> E;
E --> A;
[!TIP] Stateless vs. Stateful Strategies Our salary strategies are stateless—they don’t store any data that changes between calls. This is ideal, as we can reuse a single instance. If a strategy needed to track state, you would want the factory to create a new instance for each request to avoid data from one calculation leaking into another.
Conclusion: Why the Strategy Pattern Wins
By refactoring our code, we’ve achieved several key benefits:
- Maintainability: Each algorithm lives in its own file. Changes are isolated and safe.
- Scalability: Adding a new
Contractoremployee type is as simple as creating aContractorStrategyclass and adding it to the factory’s map. No existing code needs to be touched. - Testability: Each strategy can be unit-tested in complete isolation.
- Readability: The logic is no longer a tangled mess but a clean, high-level orchestration.
The Strategy Pattern is a powerful tool for turning rigid, conditional logic into a flexible and robust system that embraces change.
To summarize the key ideas:
mindmap
root((Strategy Pattern))
What?
:A behavioral design pattern.
:Lets you define a family of algorithms.
:Puts each algorithm in a separate class.
:Makes their objects interchangeable.
Why?
:Avoids complex if-else chains.
:Follows Open/Closed Principle.
:Improves code maintainability.
Components
Context
:The class that uses a strategy.
:Holds a reference to a strategy object.
:Delegates work to the strategy.
Strategy (Interface)
:A common interface for all algorithms.
:Ensures interchangeability.
Concrete Strategy
:Implements a specific algorithm.
Quiz: Test Your Knowledge!
1. **What is the primary SOLID principle that the Strategy Pattern helps enforce?**Solution
The **Open/Closed Principle (OCP)**. The system is *open* for extension (by adding new strategy classes) but *closed* for modification (you don't need to change the context or existing strategies). It also strongly supports the **Single Responsibility Principle (SRP)**.2. **In our Python example, what is the role of the `SalaryStrategy` ABC?**
Solution
It acts as the **Strategy Interface**. It defines a common contract (`calculate` method) that all concrete strategy classes must adhere to, ensuring they are interchangeable from the perspective of the `PayrollContext`.3. **Why is using a Factory to select the strategy often a good idea?**