3.2 - Strategy Pattern: A Behavioral Design Patterns

When designing complex software systems, it is crucial to maintain flexibility and reduce tightly coupled code. Behavioral design patterns help us achieve this by defining how classes and objects interact with one another. Among these patterns, the Strategy Pattern is a powerful tool that enables an algorithm's behavior to be selected at runtime, promoting flexibility and reusability.

What is the Strategy Pattern?

The Strategy Pattern is a behavioral design pattern that allows you to define a family of algorithms, encapsulate each one, and make them interchangeable. The key aspect of this pattern is that it enables the client to choose an algorithm from a set of algorithms dynamically, ensuring that the class is not dependent on specific implementations of an algorithm.

The core idea is that the strategy (algorithm) can vary independently from the clients that use it, making it a perfect fit for scenarios where the business logic depends on a changing algorithm.

Real-World Example

Let's take a simple example of filtering a list of integers. Based on different strategies, we can filter the integers in different ways. We will apply the Strategy Pattern to handle these filters, where each filter is a separate strategy.

Step-by-Step Breakdown

  1. Define the Strategy Interface

We need an interface that will define the filter method. Each concrete strategy will implement this interface.

package strategyPattern;

import java.util.List;

public interface FilterStrategy {
    List<Integer> filter(List<Integer> intList);
}
  1. Implement Different Filter Strategies

Now, we will create several classes, each implementing the FilterStrategy interface with a different filtering criterion.

package strategyPattern;

import java.util.List;
import java.util.stream.Collectors;

// Strategy to filter numbers greater than 20
public class GreaterThanTwentyFilter implements FilterStrategy {
    @Override
    public List<Integer> filter(List<Integer> intList) {
        return intList.stream().filter(x -> x > 20).collect(Collectors.toList());
    }
}

// Strategy to filter numbers divisible by 200
public class DivisibleBy200Filter implements FilterStrategy {
    @Override
    public List<Integer> filter(List<Integer> intList) {
        return intList.stream().filter(x -> x % 200 == 0).collect(Collectors.toList());
    }
}

// Strategy to filter numbers less than 5000
public class LessThan5000Filter implements FilterStrategy {
    @Override
    public List<Integer> filter(List<Integer> intList) {
        return intList.stream().filter(x -> x < 5000).collect(Collectors.toList());
    }
}
  1. Context Class

The context class is responsible for maintaining a reference to the chosen strategy and delegating the operation to the selected algorithm.

package strategyPattern;

import java.util.List;

public class NumberFilterContext {
    private FilterStrategy strategy;

    // Setting the strategy at runtime
    public void setStrategy(FilterStrategy strategy) {
        this.strategy = strategy;
    }

    public List<Integer> executeStrategy(List<Integer> intList) {
        return strategy.filter(intList);
    }
}
  1. Client Code (Main Method)

Finally, the client code chooses the strategy dynamically based on requirements and applies it.

package strategyPattern;

import java.util.List;

public class StrategyPatternDemo {
    public static void main(String[] args) {
        List<Integer> intList = List.of(100, 2, 300, 67, 56, 34, 12, 34, 33, 21, 66, 78, 79, 98, 56, 78, 65);

        // Create context
        NumberFilterContext context = new NumberFilterContext();

        // Strategy 1: Numbers greater than 20
        context.setStrategy(new GreaterThanTwentyFilter());
        List<Integer> result1 = context.executeStrategy(intList);
        System.out.println("Filtered with GreaterThanTwentyFilter: " + result1);

        // Strategy 2: Numbers divisible by 200
        context.setStrategy(new DivisibleBy200Filter());
        List<Integer> result2 = context.executeStrategy(intList);
        System.out.println("Filtered with DivisibleBy200Filter: " + result2);

        // Strategy 3: Numbers less than 5000
        context.setStrategy(new LessThan5000Filter());
        List<Integer> result3 = context.executeStrategy(intList);
        System.out.println("Filtered with LessThan5000Filter: " + result3);
    }
}

Advantages of the Strategy Pattern

  1. Flexibility: You can change the strategy without modifying the client code, making it highly flexible.

  2. Reusability: Different strategies are encapsulated into separate classes, improving code reusability.

  3. Open-Closed Principle: The Strategy Pattern adheres to the Open-Closed Principle by allowing new strategies to be added without changing existing code.

Use Cases

The Strategy Pattern is highly useful in scenarios where:

  • You have multiple ways of performing the same action, such as sorting or filtering, and want to switch between them dynamically.

  • You want to avoid multiple if-else or switch-case conditions that decide which algorithm to use.

Conclusion

The Strategy Pattern is an excellent way to allow dynamic selection of algorithms at runtime, offering clean separation between the client and the specific implementations. This ensures that your code remains flexible, maintainable, and adheres to fundamental design principles.

By encapsulating each filtering strategy separately, as demonstrated in our example, you can now easily add or modify filters without changing the core logic. This leads to clean, testable, and scalable code.