The Strategy Design Pattern is a behavioral design pattern that allows a class's behavior to be selected at runtime. It is particularly useful when you want to define a family of algorithms, encapsulate each one in its class, and make them interchangeable.
In this article, we will explore:
- What the Strategy Pattern is and why it’s useful.
- How code looks without the Strategy Pattern.
- How refactoring with the Strategy Pattern improves flexibility and adheres to SOLID principles.
- Two practical implementations: a calculator and a payment system.
What Is the Strategy Design Pattern?
The Strategy Pattern defines a way to create interchangeable families of algorithms by encapsulating them in separate classes and using a common interface. The client class delegates the algorithm's execution to the specific strategy it chooses.
This approach:
- Encourages separation of concerns.
- Makes adding new algorithms (strategies) straightforward.
- Reduces the risk of introducing bugs when extending functionality.
Example 1: A Calculator Without the Strategy Pattern
Let's first look at a calculator application that performs addition, subtraction, multiplication, and division without the Strategy Pattern:
public class Calculator {
private final double firstValue;
private final double secondValue;
private final String operation;
public Calculator(double firstValue, double secondValue, String operation) {
this.firstValue = firstValue;
this.secondValue = secondValue;
this.operation = operation;
}
public void result() {
switch (operation) {
case "sum":
System.out.println("The result is: " + (firstValue + secondValue));
break;
case "subtraction":
System.out.println("The result is: " + (firstValue - secondValue));
break;
case "multiply":
System.out.println("The result is: " + (firstValue * secondValue));
break;
case "division":
if (secondValue == 0) {
System.out.println("Error: Division by zero.");
} else {
System.out.println("The result is: " + (firstValue / secondValue));
}
break;
default:
System.out.println("Invalid operation.");
}
}
}
Drawbacks of This Approach
-
Violates the Open/Closed Principle: Adding a new operation requires modifying the
result
method, which increases the risk of introducing bugs. -
Hard to Extend or Test: Testing individual operations is harder because they’re tightly coupled within the
Calculator
class. -
Poor Maintainability: The
switch
statement grows with every new operation, making the code harder to read and maintain.
Refactoring with the Strategy Pattern
By applying the Strategy Pattern, we encapsulate each operation (addition, subtraction, etc.) into its own class, adhering to the Single Responsibility Principle. Here’s how the refactored calculator looks:
// Strategy Interface
public interface OperationStrategy {
double execute(double firstValue, double secondValue);
}
// Concrete Strategies
public class AdditionStrategy implements OperationStrategy {
@Override
public double execute(double firstValue, double secondValue) {
return firstValue + secondValue;
}
}
public class SubtractionStrategy implements OperationStrategy {
@Override
public double execute(double firstValue, double secondValue) {
return firstValue - secondValue;
}
}
// ... MultiplyStrategy and DivisionStrategy similar to above
// Context Class
import java.util.Map;
public class Calculator {
private final double firstValue;
private final double secondValue;
private final String operation;
private final Map<String, OperationStrategy> mapStrategies = Map.of(
"sum", new AdditionStrategy(),
"subtraction", new SubtractionStrategy(),
"multiply", new MultiplyStrategy(),
"division", new DivisionStrategy()
);
public Calculator(double firstValue, double secondValue, String operation) {
this.firstValue = firstValue;
this.secondValue = secondValue;
this.operation = operation;
}
public void result() {
OperationStrategy operationStrategy = mapStrategies.get(operation);
if (operationStrategy == null) {
System.out.println("The " + operation + " is invalid");
return;
}
double result = operationStrategy.execute(firstValue, secondValue);
System.out.println("The " + operation + " between " + firstValue + " and " + secondValue + " is " + result);
}
}
Benefits
- Open/Closed Principle: Adding a new operation requires creating a new strategy class rather than modifying existing code.
- Single Responsibility Principle: Each operation is encapsulated in its own class, making it easier to test and maintain.
-
Flexibility: Changing or extending functionality becomes trivial by modifying the
mapStrategies
mapping.
Example 2: Payment System Without the Strategy Pattern
Now, let’s look at a simple payment system without the Strategy Pattern:
public class PaymentProcessor {
private final double amount;
private final String type;
public PaymentProcessor(double amount, String type) {
this.amount = amount;
this.type = type;
}
public void totalCost() {
switch (type) {
case "money":
System.out.println("Paid " + amount + " using Money.");
break;
case "paypal":
System.out.println("Paid " + amount + " using PayPal.");
break;
case "creditcard":
System.out.println("Paid " + amount + " using Credit Card.");
break;
default:
System.out.println("Payment type not supported.");
}
}
}
Issues
- Similar to the calculator example, this violates the Open/Closed Principle.
- Adding new payment methods involves editing the
switch
statement, increasing complexity.
Refactoring the Payment System with the Strategy Pattern
Here’s how the payment system improves with the Strategy Pattern:
// Strategy Interface
public interface PaymentStrategy {
double pay(double amount);
}
// Concrete Strategies
public class Money implements PaymentStrategy {
@Override
public double pay(double amount) {
return amount;
}
}
public class PayPal implements PaymentStrategy {
@Override
public double pay(double amount) {
return amount;
}
}
public class CreditCard implements PaymentStrategy {
@Override
public double pay(double amount) {
return amount;
}
}
// Context Class
import java.util.Map;
public class PaymentProcessor {
private final double amount;
private final String type;
private final Map<String, PaymentStrategy> mapStrategies = Map.of(
"money", new Money(),
"paypal", new PayPal(),
"creditcard", new CreditCard()
);
public PaymentProcessor(double amount, String type) {
this.amount = amount;
this.type = type;
}
public void totalCost() {
PaymentStrategy strategy = mapStrategies.get(type);
if (strategy == null) {
System.out.println("The " + type + " method of payment isn't allowed.");
return;
}
double result = strategy.pay(amount);
System.out.println("Paid " + result + " using " + type + ".");
}
}
Advantages of Using the Strategy Pattern
-
Adherence to SOLID Principles:
- Single Responsibility Principle: Each strategy is responsible for its own behavior.
- Open/Closed Principle: Strategies are easily extendable without modifying existing code.
- Testability: Each strategy can be tested independently, simplifying debugging.
- Flexibility and Scalability: Adding new algorithms or payment methods requires minimal changes.
Potential Downsides
- Overhead for Small Applications: For simple systems, the additional complexity of multiple classes might be overkill.
- Dependency Management: If strategies rely on shared dependencies, you might need to inject those dependencies properly, adding complexity.
Conclusion
The Strategy Design Pattern is a powerful tool for creating maintainable, scalable, and flexible code. By encapsulating behaviors in separate classes and adhering to SOLID principles, you can reduce coupling and improve the testability of your applications.
Use the Strategy Pattern when:
- You have multiple variations of an algorithm.
- You need to switch algorithms dynamically.
- You want to make your system easy to extend without modifying existing code.
By refactoring the calculator and payment system, we've seen firsthand how the Strategy Pattern simplifies code and prepares it for future growth. Happy coding!
Top comments (0)