1. Overview
In this lesson, we’ll explore the Strategy pattern: what problem it solves, how to implement it with a family of interchangeable algorithms, and where it appears in practice. Along the way, we’ll also see how to swap strategies at runtime, how lambdas simplify the pattern, how the SOLID principles apply, and how Strategy compares to other behavioral patterns.
The relevant module we need to import when starting this lesson is: the-strategy-pattern-start.
If we want to reference the fully implemented lesson, we can import: the-strategy-pattern-end.
2. The Problem: Swapping Algorithms at Runtime
Let’s look at the start project. It contains a Task domain class with four fields: name, dueDate, importance, and estimatedEffort. A TaskScheduler orders tasks by priority before handing them to a team. The trouble is that different teams want to order work differently. One team prioritizes by the nearest dueDate, another by raw importance, and a third by a blended score that weights importance against effort.
A naive implementation collapses all three rules into a single method with branching on a mode flag:
public class TaskScheduler {
public int calculatePriority(Task task, String mode) {
return switch (mode) {
case "deadline" -> (int) -Duration.between(LocalDate.now().atStartOfDay(),
task.getDueDate().atStartOfDay()).toDays();
case "importance" -> task.getImportance();
case "effort" -> task.getImportance() * 10 - task.getEstimatedEffort();
default -> 0;
};
}
}
The challenge is immediately noticeable. Every new mode edits the same method, and tests expand to cover each branch with near-identical setup. Switching the mode at runtime means passing a flag through every caller. And the moment a fourth mode arrives (a fairness-based rule, say), the method grows another branch.
The branching also violates the Open/Closed Principle: the scheduler must be modified every time we want to extend its behavior. What we want instead is to encapsulate each priority computation as an interchangeable unit, let the scheduler hold one, and let the caller swap it without the scheduler itself changing.
3. The Strategy Pattern
Let’s start with the pattern’s purpose before looking at its participants.
3.1. Purpose
Strategy encapsulates a family of interchangeable algorithms that share the same purpose but differ in how they accomplish it, making them substitutable at runtime. That framing matters because Strategy is more than “an interface with multiple implementations.”
The defining characteristic is that the alternatives are algorithmic siblings: each one answers the same question about the same input and returns the same kind of result, so the caller can treat any of them as a drop-in for the others. Arbitrary polymorphic subclasses don’t have that contract.
The pattern gives the caller a lever: we can choose or swap the algorithm without rewriting the code that uses it, and no conditional dispatch is left in the context.
3.2. Mechanism: GoF Participants
The Strategy pattern defines three participants:
- Strategy: the interface declaring the operation common to all supported algorithms
- ConcreteStrategy: each class implementing the Strategy interface with a specific algorithm
- Context: holds a reference to a Strategy, exposes an operation to the client, and delegates the algorithm to the Strategy
The collaboration is straightforward: the client configures the Context with a ConcreteStrategy, the Context calls through the Strategy interface, and the algorithm is resolved at runtime by whichever concrete class is bound to the interface. In the next section, each of these participants gets a concrete counterpart: TaskScheduler as Context, TaskPriorityStrategy as Strategy, and three implementing classes as ConcreteStrategies.
4. Implementing the Strategy Pattern
Let’s build the pattern around task prioritization: a TaskPriorityStrategy interface, three concrete strategies, and a TaskScheduler that delegates the priority computation to whichever strategy it holds.
4.1. Class-Based Implementation
We’ll start by defining the Strategy interface in com.baeldung.ldp.strategy:
public interface TaskPriorityStrategy {
int calculate(Task task);
}
The contract is a single method that returns an int score where higher means higher priority. Implementations should be side-effect free so that swapping strategies doesn’t disturb surrounding state.
Let’s create the first implementation class – DeadlineBasedPriority. This scores each task by how soon it’s due, so tasks closer to (or past) the dueDate surface first:
public class DeadlineBasedPriority implements TaskPriorityStrategy {
@Override
public int calculate(Task task) {
return (int) -Duration.between(LocalDate.now().atStartOfDay(), task.getDueDate().atStartOfDay()).toDays();
}
}
We negate the day count so that a smaller number of days until due produces a larger score: a task overdue by two days scores 2, a task due in five days scores -5.
The second strategy is the simplest of the three. ImportanceBasedPriority returns the task’s importance value directly:
public class ImportanceBasedPriority implements TaskPriorityStrategy {
@Override
public int calculate(Task task) {
return task.getImportance();
}
}
Strategies don’t need to be equally complex. Some are elaborate; others are one line.
The third strategy blends two fields to surface high-importance, low-effort tasks first:
public class EffortWeightedPriority implements TaskPriorityStrategy {
@Override
public int calculate(Task task) {
return task.getImportance() * 10 - task.getEstimatedEffort();
}
}
The formula is illustrative. The point is that each strategy can internalize whatever model the business uses without the scheduler knowing anything about it.
Now we’re ready to build the Context. TaskScheduler takes a strategy via its constructor, exposes a prioritize() method that returns the task list ordered by the computed score, and a getPriority() helper for a single task:
public class TaskScheduler {
private TaskPriorityStrategy strategy;
public TaskScheduler(TaskPriorityStrategy strategy) {
this.strategy = strategy;
}
public int getPriority(Task task) {
return strategy.calculate(task);
}
public List<Task> prioritize(List<Task> tasks) {
return tasks.stream()
.sorted(Comparator.comparingInt(strategy::calculate).reversed())
.toList();
}
}
The scheduler has no knowledge of any concrete strategy. It sees only the TaskPriorityStrategy abstraction and delegates every priority question to whichever strategy it holds. This is the key improvement: no branching, no mode flag, no reason for the scheduler itself to change when a new algorithm arrives.
The following diagram shows the complete structure:
With all three strategies and the context in place, let’s verify each one with a test.
We’ll create StrategyPatternUnitTest in src/test/java/com/baeldung/ldp/strategy/. Each test uses the same three tasks but wires a different strategy, so the ordering change is the only variable. The first test exercises DeadlineBasedPriority end to end:
class StrategyPatternUnitTest {
@Test
void givenDeadlineStrategy_whenPrioritize_thenEarliestDueFirst() {
List<Task> tasks = List.of(
new Task("Write docs", LocalDate.now().plusDays(5), 3, 2),
new Task("Fix prod bug", LocalDate.now(), 5, 1),
new Task("Plan sprint", LocalDate.now().plusDays(2), 4, 3));
TaskScheduler scheduler = new TaskScheduler(new DeadlineBasedPriority());
List<Task> prioritized = scheduler.prioritize(tasks);
assertEquals("Fix prod bug", prioritized.get(0).getName());
assertEquals("Plan sprint", prioritized.get(1).getName());
assertEquals("Write docs", prioritized.get(2).getName());
}
}
The closest-due task comes out first and the furthest-due task comes out last, exactly as the strategy intends.
The second test reuses the same structure, this time with ImportanceBasedPriority:
@Test
void givenImportanceStrategy_whenPrioritize_thenHighestImportanceFirst() {
List<Task> tasks = List.of(
new Task("Write docs", LocalDate.now().plusDays(5), 3, 2),
new Task("Fix prod bug", LocalDate.now(), 5, 1),
new Task("Plan sprint", LocalDate.now().plusDays(2), 4, 3));
TaskScheduler scheduler = new TaskScheduler(new ImportanceBasedPriority());
List<Task> prioritized = scheduler.prioritize(tasks);
assertEquals(5, prioritized.get(0).getImportance());
assertEquals(3, prioritized.get(2).getImportance());
}
And the third uses EffortWeightedPriority, where the blended score produces an ordering neither pure-importance nor pure-effort would:
@Test
void givenEffortWeightedStrategy_whenPrioritize_thenBlendedOrdering() {
List<Task> tasks = List.of(
new Task("Write docs", LocalDate.now().plusDays(5), 3, 2),
new Task("Fix prod bug", LocalDate.now(), 5, 1),
new Task("Plan sprint", LocalDate.now().plusDays(2), 4, 3));
TaskScheduler scheduler = new TaskScheduler(new EffortWeightedPriority());
List<Task> prioritized = scheduler.prioritize(tasks);
assertEquals("Fix prod bug", prioritized.get(0).getName());
}
We can run mvn test to verify all three pass. Notice that the scheduler class is identical across all three tests. Only the strategy changes.
4.2. Swapping Strategies at Runtime
Constructor injection wires the strategy at creation time, but often we want to switch behavior on an existing context. We add a setter for that:
public class TaskScheduler {
// existing field, constructor, getPriority, and prioritize
public void setStrategy(TaskPriorityStrategy strategy) {
this.strategy = strategy;
}
}
The scheduler can now be reconfigured without being recreated. Let’s add a focused test that demonstrates the swap:
@Test
void givenScheduler_whenStrategyChanged_thenOrderingChanges() {
List<Task> tasks = List.of(
new Task("Write docs", LocalDate.now().plusDays(5), 5, 2),
new Task("Fix prod bug", LocalDate.now(), 2, 1),
new Task("Plan sprint", LocalDate.now().plusDays(2), 4, 3));
TaskScheduler scheduler = new TaskScheduler(new DeadlineBasedPriority());
List<Task> byDeadline = scheduler.prioritize(tasks);
scheduler.setStrategy(new ImportanceBasedPriority());
List<Task> byImportance = scheduler.prioritize(tasks);
assertEquals("Fix prod bug", byDeadline.get(0).getName());
assertEquals("Write docs", byImportance.get(0).getName());
}
With this task list, the urgent-but-unimportant bug surfaces first under DeadlineBasedPriority, while the important-but-distant documentation work surfaces first under ImportanceBasedPriority. The scheduler’s behavior changed without any of its own code changing. That’s the payoff of “swapping algorithms at runtime.” The third strategy, EffortWeightedPriority, plugs in the same way.
4.3. Lambda-Based Strategies
A lambda is just another form of concrete strategy: same interface, same role in the pattern, but expressed inline instead of as a named class. Because TaskPriorityStrategy has a single abstract method, it’s a functional interface, so the interface can be implemented with a lambda expression:
TaskPriorityStrategy byName = task -> task.getName().length();
TaskScheduler scheduler = new TaskScheduler(byName);
We can also pass the lambda through the setter for a runtime swap. No new class, no additional file, just an expression bound to the interface type.
In some cases, a JDK functional interface such as Function<Task, Integer> serves the same role, but a named interface such as TaskPriorityStrategy communicates pattern intent and keeps the Strategy role visible in the code. Lambda strategies shine for throwaway variants and tests; a named class is better when the strategy holds state, exposes helpers, or carries a domain meaning that deserves a name.
4.4. Selecting a Strategy
Strategy separates which algorithm from how the algorithm runs. The first decision doesn’t disappear, but it’s isolated from the second, and the selection logic can live wherever it fits best without leaking into the scheduler. Three common approaches cover most cases.
The first is a map-based lookup. We register each strategy under a key and look it up on demand:
Map<String, TaskPriorityStrategy> strategies = Map.of(
"deadline", new DeadlineBasedPriority(),
"importance", new ImportanceBasedPriority(),
"effort", new EffortWeightedPriority());
TaskPriorityStrategy chosen = strategies.getOrDefault(mode, new ImportanceBasedPriority());
This scales well when the set of strategies is open-ended or driven by user input, and it adds a sensible default for unknown keys.
The second is config-driven selection. A single property (for example, priority.mode=deadline) is read at startup and maps to one of the registered strategies. This fits best when the choice is environment-level rather than per-call.
The third is a plain switch or if. When there are only two or three strategies and they’re unlikely to grow, a direct conditional is honest and easy to read.
The right answer depends on who makes the choice and how often it changes. The selection layer can evolve on its own without the scheduler or the strategies noticing.
4.5. SOLID Principles at Work
Two SOLID principles have a strong, natural connection with Strategy.
The Open/Closed Principle (OCP) is at work. TaskScheduler is closed for modification but open for extension: adding a new priority algorithm means writing a new TaskPriorityStrategy implementation, not editing the scheduler. That’s the direct contrast with the initial branching approach.
The Dependency Inversion Principle (DIP) governs the direction of dependency. Both TaskScheduler and the concrete strategies depend on the TaskPriorityStrategy abstraction; neither depends on the other directly. High-level scheduling policy and low-level priority algorithms meet at the interface, which is what lets either side evolve without disturbing the other.
5. When to Use the Strategy Pattern / When Not To
Now that we’ve seen how Strategy works, let’s see when it fits and when it doesn’t.
5.1. When to Use
Strategy fits well in several situations:
- When we have a family of interchangeable algorithms that share the same purpose (ranking, pricing, compression, routing) and differ only in how they do it
- When we want to choose or swap the algorithm at runtime via configuration, user input, or a request parameter
- When a branching method on an algorithm-mode flag is growing and the growth shows no sign of stopping
5.2. When Not to Use
Strategy isn’t the right answer in every situation:
- When there’s one stable algorithm and no realistic need for variation. The extra interface and class add cost without benefit.
- When the alternatives share most of their behavior and differ only in a small step. Template Method (see Section 7) may be a better fit.
- When two options are truly the ceiling and unlikely to grow. A plain if/else is clear and easy to follow.
- When the selection logic itself becomes a tangle that dominates the calling code. That’s a boundary condition; Section 4.4 covers how to address it without abandoning the pattern.
6. Real-World Usage
Strategy is one of the most widely used behavioral patterns in Java. Let’s look at two examples, one deep-dive and one brief.
6.1. java.util.Comparator: Strategy in the JDK
java.util.Comparator<T> is one of the clearest Strategy families in the JDK: a single-method interface whose implementations define different comparison algorithms. The participants map cleanly to the GoF roles:
- Context: methods that use a comparator, such as List.sort(Comparator) and Stream.sorted(Comparator)
- Strategy: the Comparator<T> interface
- ConcreteStrategies: Comparator.naturalOrder(), Comparator.reverseOrder(), and field-based comparators such as Comparator.comparing(Task::getDueDate)
The parallel with our scheduler is exact: a sort operation delegates the comparison algorithm to a strategy object, and swapping the comparator swaps the ordering without touching the sort implementation. Ordering our tasks by due date is a one-liner:
tasks.sort(Comparator.comparing(Task::getDueDate));
Comparator.comparing(Task::getDueDate) is itself a lambda-constructed strategy.
6.2. Spring’s PasswordEncoder
Spring Security’s PasswordEncoder family is another Strategy family. BCryptPasswordEncoder, Pbkdf2PasswordEncoder, and Argon2PasswordEncoder are interchangeable hashing algorithms behind a single interface.
DelegatingPasswordEncoder is a concrete production example of the runtime-selection promise. It inspects a prefix on the stored password (for example, {bcrypt} or {argon2}) and routes to the matching encoder, so a single application can verify passwords encoded by different algorithms over time. The selection is data-driven rather than manual, but the underlying mechanism is the same strategy swap we saw with setStrategy().
7. Related Patterns
Let’s briefly compare Strategy with two patterns that are structurally close, using the task-priority example as the anchor.
Strategy vs. Template Method. Both parameterize an algorithm, but through different mechanisms. Strategy uses composition: TaskScheduler holds a TaskPriorityStrategy and delegates. Template Method uses inheritance: a subclass overrides specific steps of a fixed skeleton. If we wanted every priority calculation to follow a fixed sequence (validate the task, compute the score, log the result), Template Method would be a better fit. If the algorithms are entirely distinct computations, such as our three priority formulas, Strategy wins.
Strategy vs. Command. Strategy encapsulates a pure algorithm: calculate(Task) takes input and returns a score, nothing more. Command encapsulates a request: what to do, plus metadata such as who requested it, when, and how to undo it. A PrioritizeTasksCommand would carry the request context around the scheduler call; a TaskPriorityStrategy just computes a value.
8. Conclusion
In this lesson, we’ve explored the Strategy pattern, which replaces branching on an algorithm-mode flag with a family of interchangeable algorithms sharing a common interface. The context delegates to whichever strategy it holds, so the choice of algorithm becomes a design-time and runtime lever rather than code to rewrite.
When multiple implementations of the same operation differ only in algorithm, composing them behind a shared interface keeps the surrounding code stable and open to new variations.