Encapsulating interchangeable algorithms behind a common interface, swappable at runtime — and the maintainability argument for choosing it over an if/else chain.
Published September 23, 2026
Strategy encapsulates a family of interchangeable algorithms behind one interface, so the code using them doesn't need to know or care which specific one is active.
interface PricingStrategy { double calculate(Order order); }
class RegularPricing implements PricingStrategy {
public double calculate(Order order) { return order.subtotal(); }
}
class BulkDiscountPricing implements PricingStrategy {
public double calculate(Order order) { return order.subtotal() * 0.9; }
}
class SeasonalPricing implements PricingStrategy {
public double calculate(Order order) { return order.subtotal() * 0.85; }
}
class Checkout {
private final PricingStrategy strategy; // injected — chosen by the caller, not hard-coded here
Checkout(PricingStrategy strategy) { this.strategy = strategy; }
double total(Order order) { return strategy.calculate(order); }
}
Checkout regular = new Checkout(new RegularPricing());
Checkout bulk = new Checkout(new BulkDiscountPricing());
// Same Checkout class, different pricing behavior — chosen at construction, swappable at runtime
// Without Strategy — every new pricing rule means editing this method
double total(Order order, String pricingType) {
if (pricingType.equals("REGULAR")) return order.subtotal();
else if (pricingType.equals("BULK")) return order.subtotal() * 0.9;
else if (pricingType.equals("SEASONAL")) return order.subtotal() * 0.85;
throw new IllegalArgumentException("Unknown pricing type");
}
This is the exact same OCP violation shown in Single Responsibility & Open/Closed's payment-method example: every new pricing rule means opening this method, adding a branch, and re-testing all existing branches for regression risk. With Strategy, adding a new pricing rule means writing one new class — the Checkout class (and anything else consuming PricingStrategy) never changes. This is the specific argument to give an interviewer who asks "why not just use if/else": it isn't about the if/else being slow, it's about every new case requiring a change to code that was already tested and shipped.
interface SortStrategy { void sort(int[] data); }
class BubbleSort implements SortStrategy { public void sort(int[] data) { /* O(n^2), simple */ } }
class QuickSort implements SortStrategy { public void sort(int[] data) { /* O(n log n) average */ } }
class Sorter {
private SortStrategy strategy;
void setStrategy(SortStrategy strategy) { this.strategy = strategy; } // can even swap at runtime, not just construction
void sort(int[] data) { strategy.sort(data); }
}
A Sorter could pick BubbleSort for tiny, nearly-sorted inputs and QuickSort for large ones — the point isn't that one algorithm is universally better, it's that the choice of algorithm is isolated from the code that needs sorting done.
The if/else chain removed from Checkout tends to reappear wherever the strategy is chosen. Two clean ways to keep selection tidy:
1. A registry keyed by type (built by hand, or by Spring):
enum PricingType { REGULAR, BULK, SEASONAL }
interface PricingStrategy {
PricingType type();
Money calculate(Order order);
}
@Component
class PricingStrategies {
private final Map<PricingType, PricingStrategy> byType;
PricingStrategies(List<PricingStrategy> all) { // Spring injects every implementation
this.byType = all.stream().collect(Collectors.toMap(PricingStrategy::type, s -> s));
}
PricingStrategy forType(PricingType type) {
PricingStrategy s = byType.get(type);
if (s == null) throw new IllegalArgumentException("No pricing strategy for " + type);
return s;
}
}
Adding a new pricing rule is now one new class annotated with @Component. Neither the registry nor any caller changes. Failing loudly on an unknown type is deliberate: a silent fallback to "regular price" would hide configuration bugs.
2. Selection by rule, when the choice depends on the data rather than an explicit type:
class PricingPolicy {
private final List<Map.Entry<Predicate<Order>, PricingStrategy>> rules; // first match wins
private final PricingStrategy fallback;
PricingPolicy(List<Map.Entry<Predicate<Order>, PricingStrategy>> rules, PricingStrategy fallback) {
this.rules = rules; this.fallback = fallback;
}
PricingStrategy choose(Order order) {
return rules.stream().filter(r -> r.getKey().test(order)).map(Map.Entry::getValue)
.findFirst().orElse(fallback);
}
}
The ordering of rules is now data, visible in one place, and testable, rather than spread across nested conditionals.
For single-method strategies with no state, Java's functional interfaces are enough:
Map<PricingType, Function<Order, Money>> pricing = Map.of(
PricingType.REGULAR, Order::subtotal,
PricingType.BULK, o -> o.subtotal().times(0.90),
PricingType.SEASONAL, o -> o.subtotal().times(0.85));
List<Employee> sorted = employees.stream()
.sorted(Comparator.comparing(Employee::department).thenComparing(Employee::salary).reversed())
.toList(); // Comparator IS a strategy object
Comparator, Runnable, Function and friends are the JDK's everyday strategies. Promote a lambda to a named class when it needs dependencies (a tax-rate service), configuration, multiple methods, or its own tests.
Each strategy is tested alone, with simple inputs, and the context class is tested with a stub strategy. Neither test needs the other's complexity:
@Test void bulkPricingGivesTenPercentOff() {
Order order = orderWithSubtotal(Money.of(1000));
assertEquals(Money.of(900), new BulkDiscountPricing().calculate(order));
}
@Test void checkoutUsesWhateverStrategyItIsGiven() {
Checkout checkout = new Checkout(order -> Money.of(42)); // a stub strategy via a lambda
assertEquals(Money.of(42), checkout.total(anyOrder()));
}
This separation is one of the pattern's most practical benefits. Pricing edge cases (rounding, minimum order values) are tested once, in the strategy, not re-tested through every class that uses pricing.
Q: How is Strategy different from just passing a lambda/functional interface?
A: For simple, single-method behavior, a Function/Comparator-style lambda often is a lightweight Strategy — Java's functional interfaces are Strategy without the ceremony of a named class. Strategy as a named-class pattern earns its keep when the "algorithm" needs multiple methods, internal state, or a constructor with configuration — beyond what a bare lambda can express.
Q: Isn't Strategy the same pattern as Bridge? A: Structurally similar (both compose a reference instead of inheriting), but different intent and scope: Strategy typically swaps one algorithm at a single decision point; Bridge decouples two entire class hierarchies so both can evolve independently over an object's whole lifetime (see Bridge & Flyweight).
Q: Where would you put the logic that decides WHICH strategy to use?
A: That decision itself is exactly what a Factory (see Factory & Abstract Factory) is for — Strategy and Factory commonly pair up: a factory method takes a type/condition and returns the appropriate PricingStrategy instance, keeping the selection logic in one place too.
Q: How do you choose a strategy at runtime without reintroducing a big switch statement?
A: Build a map from a key (an enum, or a string from configuration) to strategy instances. In Spring, inject List<PricingStrategy> and index it by each strategy's declared type. For data-dependent choices, use an ordered list of predicate → strategy rules.
Q: Can a strategy hold state? A: Yes, but prefer immutable configuration (rates, thresholds) set at construction. Mutable per-call state inside a strategy that's shared between threads (for example, a Spring singleton) is a concurrency bug. Keep per-call data in method parameters and local variables.
Q: How does Strategy relate to dependency injection? A: DI is the mechanism that supplies the chosen strategy to the class that uses it. Strategy is the design that makes the behaviour swappable in the first place. Together, they let configuration or profiles decide behaviour without code changes.