Understanding the Strategy Pattern
A colleague of mine is currently trying to study and learn Design Patterns. For the uninitiated, the term Design Pattern often refers to a set of well-known patterns used to solve problems that often present themselves in software. The primary work was originally written by Gamma et. al in Design Patterns: Elements of Reusable Object-Oriented Software. My colleague was reading an article on the internet on the Strategy Pattern, and voiced frustration at how contrived the examples were in the article, and how this seems to be a recurring pattern (no pun intended) when we create content around Design Patterns for one another.
This post is my attempt to clarify information about the Strategy Pattern for my colleague, and my hope is that this effort will benefit a large audience of developers experiencing the same frustrations. Let’s get started.
Introducing the Strategy Pattern
Before we can meaningfully discuss how to use the Strategy Pattern in applications, we need to define it. The Wikipedia article on the subject is a good starting point for this:
In computer programming, the strategy pattern (also known as the policy pattern) is a behavioral software design pattern that enables selecting an algorithm at runtime. Instead of implementing a single algorithm directly, code receives run-time instructions as to which in a family of algorithms to use.
Strategy lets the algorithm vary independently from clients that use it. Strategy is one of the patterns included in the influential book Design Patterns by Gamma et al. that popularized the concept of using design patterns to describe how to design flexible and reusable object-oriented software. Deferring the decision about which algorithm to use until runtime allows the calling code to be more flexible and reusable.
Using this definition, it follows that:
- Clients are presented with a single unified interface for how they interact with a system.
- The system can conditionally modify it’s behavior based on attributes provided by the client.
- Strategies can be decomposed at design-time, thereby simplifying their implementation.
The last point is the most significant: while it is certainly possible to maintain a monolithic implementation of an algorithm in software, it’s not generally desirable to do so. The ability to select an algorithm dynamically at runtime provides several benefits:
- Testing of the interface clients use is reduced to the behavior of that interface—not the implementation of one or more algorithms.
- The algorithms can be tested in isolation.
- By designing our software systems in this manner, we can easily follow the Single-Responsibility Principle.
Strategy Pattern Use-cases
Now that we understand what the Strategy pattern is, let’s talk about some use-cases. What should we do with this pattern? Some obvious examples that come to mind are:
- In recommendation systems, we may want to change our choice of algorithm for creating a recommendation.
- In an application using Large Language Models (LLMs), we may want to use a cheap LLM for vectorizing a user-query for searching a vector database, but a more powerful LLM for generating a response to the user’s query.
- The langchain library has examples of this.
The key here is that we can clearly state the ways in which we want our system to be able to perform a task at design-time, but we defer the commitment to which of our pre-defined methods is selected until runtime.
Modeling a System
Now that we’ve covered definitions and use-cases, let’s work out a simple example. We’re going to model a service that recommends movies to it’s users. Here’s a prompt about the system we’ll be modeling:
The first version of our service used a very simple strategy where we randomly selected movies from the movie catalogue. Predictably, this wasn’t very useful to users, so we added a feature that makes personalized recommendations based on the user’s preferences. While many users prefer this version, not all users do: some prefer that we don’t track information about them, and others feel our system is over-fit to their preferences, resulting in a narrow set of results in their feed. Because we care about our customers and their preferences, we wish to support each of these audiences, and will continue to offer both versions. We also wish to do this in such a way as to support onboarding of new flavors of the recommendation systems in the future.
This may sound a little silly, however, it’s actually not very different from a real recommendation system that I worked on in the past. I changed details of the story, but the overall goals were fairly similar to what’s stated here.
Sample Code
This section demonstrates sample code that covers how we might implement a system similar to the one described above. The code is somewhat incomplete, and you will not be able to compile it, but I believe it should be sufficient to outline the salient parts of how you would use the Strategy pattern to solve the problem described in the previous section.
First up is defining our “strategy” interface. This is the object that gets called to make a recommendation. We want to be able to cheaply define and introduce new flavors of this within our system, and we want them to be logically isolated with respect to one another. Here’s the code:
public interface IMovieRecommender {
MovieRecommendation RecommendMovie(RecommendationContext context);
}
public record RecommendationContext(
UserProfile UserProfile
);
public record MovieRecommendation(
string Title
);
First is our IMovieRecommender
interface. This is the component of the system that is swappable. Next,
we have the RecommendationContext
. This allows us to determine how we wish to service the request, and
also provides some general data about the task being performed that can be used to load related information
(if needed). Finally, we have the output recommendation itself.
As outlined in the previous sections, our system knows how to make two kinds of recommendation: personalized recommendations based on data held in the user profile, and random recommendations (the default behavior). We’ll also delegate creation of the specific recommenders to a factory so as to avoid polluting our business logic with details about how to create specific instances of our strategies. The strategies and factory are shown below:
public enum MovieRecommenderStrategyKind
{
Default = 0,
Random,
Personalized
}
public class MovieRecommenderFactory
{
// Note: the `virtual` keyword means we can create a child class that
// overrides this method and returns... Pretty much whatever we want.
public virtual IMovieRecommender Create(MovieRecommenderStrategyKind kind)
{
switch (kind)
{
case MovieRecommenderStrategyKind.Personalized:
return new PersonalizedMovieRecommender();
case MovieRecommenderStrategyKind.Random:
goto default;
default:
return new RandomMovieRecommender();
}
}
}
One important detail about the MovieRecommendationFactory
is that it can be sub-typed, and therefore,
replaced. The Create(MovieRecommenderStrategyKind)
method is but a suggestion or “hint” to the factory;
it’s free to create whatever implementation of the IMovieRecommender
it wants. This would make it easy
to replace implementations with fakes during testing, if desired.
The way our clients (“users”) interact with this system is through the following service class:
public class MovieRecommendationService
{
private MovieRecommenderFactory _factory;
public MovieRecommendationService() : this(new MovieRecommenderFactory()) { }
public MovieRecommendationService(MovieRecommenderFactory factory)
{
_factory = factory;
}
public MovieRecommendation RecommendMovie(RecommendationContext context)
{
// Choose which "strategy" to use for making this particular recommendation, based
// on the ambient context and related data acquired above.
var strategy = GetStrategy(context, userProfile);
// Now, make the actual recommendation.
var recommendation = strategy.RecommendMovie(context);
// Finally, provide the user with the recommendation.
return recommendation;
}
private IMovieRecommender GetStrategy(RecommendationContext context)
{
if (context.UserProfile.EnablePersonalizedRecommendations)
{
return _factory.Create(MovieRecommenderStrategyKind.Personalized);
}
else
{
return _factory.Create(MovieRecommenderStrategyKind.Random);
}
}
}
What we have here is hopefully straight forward. The MovieRecommendationService
is the client-facing part
of our system, and is responsible for the overall task of creating recommendations for our users. As part
of that task, it examines information in the user profile to determine if we can make personalized
recommendations for the user making the request. When the user has opted-in to the personalized recommendation
system (or not opted out), we will use that strategy; otherwise, use the “random” strategy that other users
prefer.
Closing Thoughts
In this article, I’ve covered what the Strategy pattern is, some use-cases for where it is useful, and provided some sample code that resembles what you would see in a real system. While the sample code is incomplete, it is demonstrative of the key principles needed to understand what’s going on.
I hope you’ve found this helpful!