Strategy Design Pattern in Java
The strategy design pattern in Java puts each variant of an algorithm, such as a discount rule, a sort order or a payment-routing policy, into its own class behind a common interface. The object that needs the algorithm holds a reference to that interface, so the actual rule can be chosen or swapped at runtime without changing the code that uses it.
It matters because business rules multiply. Marketing adds a new campaign every month, product adds sort options, and finance negotiates new gateway fees. If each rule is another branch in a checkout method, that method grows forever and every change risks breaking the others. Strategy is a behavioral design pattern, and Java developers use it constantly, often without naming it, every time they pass a Comparator.
1. What Is the Strategy Design Pattern?
The GoF intent is: "Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it."
The pattern splits the code into two parts. The context is the class that does a job, for example checkout. One step of that job can be done in several ways, so the context does not implement that step itself; it delegates to a strategy object through an interface. Each concrete strategy implements one way of doing the step.
The strategy is usually passed in through the constructor or a setter, or looked up by name from configuration. Swapping it changes the context's behaviour without editing the context. This is composition over inheritance in its clearest form, and it follows the Open/Closed Principle (new rules are new classes) and the Dependency Inversion Principle (the context depends on an abstraction, not on concrete rules). In modern Java, a strategy interface with one method is a functional interface, so short strategies can be lambdas.
2. Key Components
| Component | Responsibility | In the Basic Example |
|---|---|---|
| Strategy | Interface shared by all algorithm variants | DiscountStrategy |
| Concrete Strategies | One algorithm each | NoDiscount, PercentageOff, FlatOff, TieredDiscount |
| Context | Holds a strategy and calls it when the step is needed | CheckoutService |
| Client | Chooses the strategy and gives it to the context | main |
The client picks a strategy, often from configuration or the request, and hands it to the context. When the context reaches the variable step, it calls the strategy's method and uses the result. The context never checks which strategy it holds.
3. When Is Strategy Useful?
- Campaign rules: percentage, flat and tiered discounts that marketing switches on and off.
- User-selected behaviour: search results sorted by price, rating or recency, depending on a request parameter.
- Per-tenant policies: each merchant chooses how payments are routed between gateways.
4. UML Class Diagram
The diagram shows the Basic Example.
CheckoutService holds one DiscountStrategy and delegates to it, so the client can swap the discount rule at runtime.CheckoutService(context) holds exactly oneDiscountStrategy(solid arrow, multiplicity 1).- The four concrete strategies implement the interface (dashed lines, hollow triangle).
- The context has no arrows to any concrete strategy: it cannot depend on a specific rule.
- The client creates a concrete strategy and passes it in with
setDiscount().
5. Step-by-Step Execution Flow
- The client creates a
CheckoutService; its default strategy isNoDiscount. - The client creates
new PercentageOff(10)and callssetDiscount(). - The client calls
checkout.total(6400.00). - The context calls
discount.apply(6400.00); dynamic dispatch runsPercentageOff.apply(), which returns 5760.00. - The context returns that total. Later, the client sets a
TieredDiscount, and the sametotal()call now applies the tiered rule.
6. Real-World Use Cases
- E-commerce: discount, shipping-fee, tax and search-ranking rules.
- Payments and banking: gateway routing, fee calculation, interest calculation per product, and fraud-scoring models.
- Notifications: retry and back-off policies per channel.
- The JDK:
Comparatoris a sorting strategy passed toList.sort()andCollections.sort().ThreadPoolExecutortakes aRejectedExecutionHandlersuch asAbortPolicy(the default),CallerRunsPolicy,DiscardPolicyorDiscardOldestPolicy. - Spring: Spring Security's
PasswordEncoderhas interchangeable implementations such asBCryptPasswordEncoderandArgon2PasswordEncoder. Spring also lets you inject aMap<String, YourStrategy>of all strategy beans and pick one by name at runtime.
7. Problem Without the Pattern
A checkout method with every campaign as a branch:
import java.math.BigDecimal;
import java.math.RoundingMode;
public class CheckoutWithoutStrategy {
// Every campaign type is another branch inside the checkout
static BigDecimal total(BigDecimal subtotal, String campaign) {
if (campaign.equals("NONE")) {
return subtotal;
} else if (campaign.equals("DIWALI_10")) {
return subtotal.multiply(new BigDecimal("0.90")).setScale(2, RoundingMode.HALF_UP);
} else if (campaign.equals("FLAT_500")) {
return subtotal.compareTo(new BigDecimal("2000")) >= 0 ? subtotal.subtract(new BigDecimal("500")) : subtotal;
} else if (campaign.equals("BULK")) {
// tier rules copied from the marketing spec; the cart page has its own copy
if (subtotal.compareTo(new BigDecimal("10000")) >= 0) return subtotal.multiply(new BigDecimal("0.85")).setScale(2, RoundingMode.HALF_UP);
if (subtotal.compareTo(new BigDecimal("5000")) >= 0) return subtotal.multiply(new BigDecimal("0.92")).setScale(2, RoundingMode.HALF_UP);
return subtotal;
}
throw new IllegalArgumentException("Unknown campaign " + campaign);
}
public static void main(String[] args) {
BigDecimal subtotal = new BigDecimal("6400.00");
for (String campaign : new String[] {"NONE", "DIWALI_10", "FLAT_500", "BULK"}) {
System.out.println(campaign + ": Rs. " + total(subtotal, campaign));
}
}
}
NONE: Rs. 6400.00
DIWALI_10: Rs. 5760.00
FLAT_500: Rs. 5900.00
BULK: Rs. 5888.00
- An ever-growing method: each campaign adds a branch, and every change re-tests all of them.
- Duplication: the cart page keeps its own copy of the tier rules, which will drift.
- String-typed choices: a typo in a campaign name is only caught at runtime.
- Hard to test: each rule can only be tested through the whole checkout.
8. Solution Using the Pattern
Define a DiscountStrategy interface, write one small class per campaign, and give the checkout a strategy field. Checkout calls discount.apply(subtotal) and nothing else.
The results are identical, but the structure is not. A new campaign is one new class, and checkout never changes. The cart page reuses the same strategy objects instead of copying rules. Each strategy has its own unit tests, and campaigns can be switched at runtime, for example from a feature flag.
9. Strategy Design Pattern in Java: Code Examples
Each example is a single file that runs on Java 21 with java FileName.java.
Basic Example: Checkout Discount Campaigns
Problem: fix section 7 so that each campaign is its own class and checkout never changes when campaigns do.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
public class DiscountStrategyDemo {
// Strategy
interface DiscountStrategy {
BigDecimal apply(BigDecimal subtotal);
String describe();
}
// Concrete strategies: one rule each
static final class NoDiscount implements DiscountStrategy {
public BigDecimal apply(BigDecimal subtotal) { return subtotal; }
public String describe() { return "no discount"; }
}
record PercentageOff(int percent) implements DiscountStrategy {
public BigDecimal apply(BigDecimal subtotal) {
return subtotal.multiply(BigDecimal.valueOf(100 - percent)).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
public String describe() { return percent + "% off"; }
}
record FlatOff(BigDecimal amount, BigDecimal minimumSpend) implements DiscountStrategy {
public BigDecimal apply(BigDecimal subtotal) {
return subtotal.compareTo(minimumSpend) >= 0 ? subtotal.subtract(amount) : subtotal;
}
public String describe() { return "Rs. " + amount + " off above Rs. " + minimumSpend; }
}
static final class TieredDiscount implements DiscountStrategy {
public BigDecimal apply(BigDecimal subtotal) {
int percent = subtotal.compareTo(new BigDecimal("10000")) >= 0 ? 15
: subtotal.compareTo(new BigDecimal("5000")) >= 0 ? 8 : 0;
return new PercentageOff(percent).apply(subtotal); // strategies can reuse each other
}
public String describe() { return "8% above Rs. 5000, 15% above Rs. 10000"; }
}
// Context: uses whatever strategy it is given
static final class CheckoutService {
private DiscountStrategy discount = new NoDiscount();
void setDiscount(DiscountStrategy discount) { this.discount = discount; }
BigDecimal total(BigDecimal subtotal) { return discount.apply(subtotal); }
String activeCampaign() { return discount.describe(); }
}
public static void main(String[] args) {
CheckoutService checkout = new CheckoutService();
BigDecimal subtotal = new BigDecimal("6400.00");
List<DiscountStrategy> campaigns = List.of(
new NoDiscount(),
new PercentageOff(10),
new FlatOff(new BigDecimal("500"), new BigDecimal("2000")),
new TieredDiscount());
for (DiscountStrategy campaign : campaigns) {
checkout.setDiscount(campaign); // swap the algorithm at runtime
System.out.println(checkout.activeCampaign() + ": Rs. " + checkout.total(subtotal));
}
}
}
Important classes and methods
PercentageOffandFlatOffare records: their settings are part of the strategy and cannot change after creation.TieredDiscountreusesPercentageOffinternally, showing that strategies can build on each other.CheckoutServicestarts with a safe default,NoDiscount, so it never has a null strategy.
Expected output
no discount: Rs. 6400.00
10% off: Rs. 5760.00
Rs. 500 off above Rs. 2000: Rs. 5900.00
8% above Rs. 5000, 15% above Rs. 10000: Rs. 5888.00
How it runs
- The loop swaps the strategy four times on the same checkout object.
- Each call to
total()gives the same answers as the old if-else version. - 6400.00 falls in the 8% tier, giving 5888.00.
Practical Example: Search Sort Options with Comparators
Problem: the product search page offers several sort orders chosen by a URL parameter, with tie-breaking rules and a safe default for unknown values.
import java.time.LocalDate;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
public class ProductSortStrategyDemo {
record Product(String name, int priceInRupees, double rating, int reviews, LocalDate listedOn) { }
// Strategies are Comparators: the JDK's own strategy interface, written as lambdas and method references
static final Map<String, Comparator<Product>> SORT_OPTIONS = Map.of(
"price_asc", Comparator.comparingInt(Product::priceInRupees),
"price_desc", Comparator.comparingInt(Product::priceInRupees).reversed(),
"rating", Comparator.comparingDouble(Product::rating).reversed()
.thenComparing(Comparator.comparingInt(Product::reviews).reversed()), // tie-breaker
"newest", Comparator.comparing(Product::listedOn).reversed());
// Context: the search service sorts with whichever strategy the request names
static List<String> search(List<Product> catalogue, String sortParam) {
Comparator<Product> strategy = SORT_OPTIONS.getOrDefault(sortParam, SORT_OPTIONS.get("rating")); // safe default
return catalogue.stream().sorted(strategy).map(Product::name).toList();
}
public static void main(String[] args) {
List<Product> headphones = List.of(
new Product("Aurel Pro", 7_999, 4.6, 1_240, LocalDate.of(2026, 3, 14)),
new Product("Bolt Air", 2_499, 4.2, 8_310, LocalDate.of(2025, 11, 2)),
new Product("Corva Max", 12_999, 4.6, 3_020, LocalDate.of(2026, 9, 21)),
new Product("Dune Lite", 1_299, 3.9, 640, LocalDate.of(2026, 7, 8)));
for (String sort : List.of("price_asc", "price_desc", "rating", "newest", "unknown")) {
System.out.println(String.format("%-10s -> ", sort) + search(headphones, sort));
}
}
}
Important classes and methods
- Each sort option is a
Comparator<Product>, the JDK's strategy interface, built with method references; no extra classes are needed. reversed()andthenComparing()combine small strategies into new ones.- The map acts as a registry: the request parameter selects a strategy, and
getOrDefault()handles unknown values.
Expected output
price_asc -> [Dune Lite, Bolt Air, Aurel Pro, Corva Max]
price_desc -> [Corva Max, Aurel Pro, Bolt Air, Dune Lite]
rating -> [Corva Max, Aurel Pro, Bolt Air, Dune Lite]
newest -> [Corva Max, Dune Lite, Aurel Pro, Bolt Air]
unknown -> [Corva Max, Aurel Pro, Bolt Air, Dune Lite]
How it runs
- Each sort parameter picks a different comparator; the search code is identical for all.
- Aurel Pro and Corva Max share a 4.6 rating, so the tie-breaker puts Corva Max, with more reviews, first.
- "unknown" falls back to the rating strategy.
Real-World Enterprise Example: Per-Merchant Payment Routing
Problem: a payment platform supports several gateways with different fees, success rates and capabilities. Each merchant chooses a routing policy, and the router serves all merchants concurrently.
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class PaymentRoutingDemo {
record Gateway(String name, BigDecimal feePercent, boolean upiSupported) { }
record Payment(String id, String method, BigDecimal amount) { }
// Live success statistics, updated by many request threads
static final class GatewayStats {
private final Map<String, AtomicInteger> attempts = new ConcurrentHashMap<>();
private final Map<String, AtomicInteger> successes = new ConcurrentHashMap<>();
void record(String gateway, boolean success) {
attempts.computeIfAbsent(gateway, g -> new AtomicInteger()).incrementAndGet();
if (success) successes.computeIfAbsent(gateway, g -> new AtomicInteger()).incrementAndGet();
}
double successRate(String gateway) {
int tries = attempts.getOrDefault(gateway, new AtomicInteger()).get();
return tries == 0 ? 1.0 : successes.getOrDefault(gateway, new AtomicInteger()).get() / (double) tries;
}
}
// Strategy: picks a gateway for a payment
interface RoutingStrategy {
Gateway choose(Payment payment, List<Gateway> candidates);
}
// Concrete strategies are stateless, so one instance can serve every request
static final class LowestFeeRouting implements RoutingStrategy {
public Gateway choose(Payment payment, List<Gateway> candidates) {
return candidates.stream().min(Comparator.comparing(Gateway::feePercent)).orElseThrow();
}
}
static final class BestSuccessRateRouting implements RoutingStrategy {
private final GatewayStats stats;
BestSuccessRateRouting(GatewayStats stats) { this.stats = stats; }
public Gateway choose(Payment payment, List<Gateway> candidates) {
return candidates.stream().max(Comparator.comparingDouble(g -> stats.successRate(g.name()))).orElseThrow();
}
}
record PreferredRouting(String preferred, RoutingStrategy fallback) implements RoutingStrategy {
public Gateway choose(Payment payment, List<Gateway> candidates) {
return candidates.stream().filter(g -> g.name().equals(preferred)).findFirst()
.orElseGet(() -> fallback.choose(payment, candidates)); // strategies can wrap other strategies
}
}
// Context: filters gateways that can take the payment, then delegates the choice
static final class PaymentRouter {
private final List<Gateway> gateways;
private final Map<String, RoutingStrategy> strategyByMerchant;
private final RoutingStrategy defaultStrategy;
PaymentRouter(List<Gateway> gateways, Map<String, RoutingStrategy> strategyByMerchant, RoutingStrategy defaultStrategy) {
this.gateways = List.copyOf(gateways);
this.strategyByMerchant = Map.copyOf(strategyByMerchant);
this.defaultStrategy = defaultStrategy;
}
String route(String merchant, Payment payment) {
List<Gateway> eligible = gateways.stream()
.filter(g -> !payment.method().equals("UPI") || g.upiSupported())
.toList();
Gateway chosen = strategyByMerchant.getOrDefault(merchant, defaultStrategy).choose(payment, eligible);
BigDecimal fee = payment.amount().multiply(chosen.feePercent()).movePointLeft(2).setScale(2, RoundingMode.HALF_UP);
return "%s for %s -> %s (fee Rs. %s)".formatted(payment.id(), merchant, chosen.name(), fee);
}
}
public static void main(String[] args) {
List<Gateway> gateways = List.of(
new Gateway("PayAlpha", new BigDecimal("1.80"), true),
new Gateway("PayBeta", new BigDecimal("1.95"), true),
new Gateway("PayGamma", new BigDecimal("1.60"), false));
GatewayStats stats = new GatewayStats();
for (int i = 0; i < 100; i++) { // yesterday's traffic
stats.record("PayAlpha", i % 10 != 0); // 90% success
stats.record("PayBeta", i % 50 != 0); // 98% success
stats.record("PayGamma", i % 5 != 0); // 80% success
}
PaymentRouter router = new PaymentRouter(gateways, Map.of(
"budget-mart", new LowestFeeRouting(),
"luxe-store", new BestSuccessRateRouting(stats),
"travel-hub", new PreferredRouting("PayGamma", new LowestFeeRouting())),
new LowestFeeRouting());
System.out.println(router.route("budget-mart", new Payment("P-1", "CARD", new BigDecimal("2000"))));
System.out.println(router.route("budget-mart", new Payment("P-2", "UPI", new BigDecimal("2000"))));
System.out.println(router.route("luxe-store", new Payment("P-3", "CARD", new BigDecimal("48000"))));
System.out.println(router.route("travel-hub", new Payment("P-4", "CARD", new BigDecimal("15000"))));
System.out.println(router.route("travel-hub", new Payment("P-5", "UPI", new BigDecimal("15000"))));
System.out.println(router.route("new-merchant", new Payment("P-6", "CARD", new BigDecimal("999"))));
}
}
Important classes and methods
PaymentRouter(context) filters gateways that can take the payment, then delegates the choice to the merchant's strategy.PreferredRoutingwraps a fallback strategy, so strategies can be composed.- The strategies hold no per-request state, so one instance is shared safely; the live statistics they read use
ConcurrentHashMapandAtomicInteger.
Expected output
P-1 for budget-mart -> PayGamma (fee Rs. 32.00)
P-2 for budget-mart -> PayAlpha (fee Rs. 36.00)
P-3 for luxe-store -> PayBeta (fee Rs. 936.00)
P-4 for travel-hub -> PayGamma (fee Rs. 240.00)
P-5 for travel-hub -> PayAlpha (fee Rs. 270.00)
P-6 for new-merchant -> PayGamma (fee Rs. 15.98)
How it runs
- budget-mart takes the cheapest gateway: PayGamma for cards, but PayAlpha for UPI, which PayGamma does not support.
- luxe-store chooses PayBeta, the gateway with the best success rate yesterday (98%).
- travel-hub prefers PayGamma and falls back to the lowest fee when PayGamma is not eligible.
- An unknown merchant gets the default strategy.
10. Best Practices and Common Mistakes
Best practices
- Keep the strategy interface small, ideally one method, so lambdas can implement it.
- Give the context a sensible default strategy instead of allowing
null. - Register strategies by name (a map or Spring beans) when the choice comes from configuration.
- Thread safety: shared strategies must be stateless or immutable. If a strategy needs mutable data, keep it in thread-safe structures or create one instance per request.
Common mistakes
- Moving the
if-elseelsewhere: choosing strategies with a big switch in every caller instead of one registry. - Context type checks such as
instanceof PercentageOff, which defeat the abstraction. - Fat interfaces that force every strategy to implement methods it does not need.
- Overengineering: a strategy hierarchy for one rule that has never changed.
11. When to Use and When Not to Use
Use it when the same step has several variants that change independently of the surrounding logic, when the variant is chosen by data such as configuration, user input or tenant, or when each variant deserves its own tests.
Avoid it when there is only one algorithm and no realistic second, when the choice is fixed at compile time and the variants are trivial, or when the variants differ in many related steps, where Template Method or separate classes fit better.
12. Advantages and Disadvantages
Advantages
- No conditional sprawl in the context.
- Runtime swapping of behaviour.
- Open/Closed: new algorithms are new classes or lambdas.
- Isolated testing of each algorithm.
Disadvantages
- More types to navigate, unless lambdas are used.
- The client must know which strategies exist to choose one.
- A common interface may force all strategies to accept data some do not need.
13. Comparison with Similar Patterns
| Aspect | Strategy | State | Template Method | Command |
|---|---|---|---|---|
| Varies | One algorithm, as a whole | Behaviour by lifecycle state | Some steps of a fixed algorithm | The request itself |
| Mechanism | Composition | Composition | Inheritance | Objects holding a receiver |
| Who switches | The client | The states themselves | Nobody: fixed by the subclass | Not applicable |
| Typical Java form | Comparator, lambdas | Enum or state classes | Abstract class with final method | Runnable, command objects |
Choose Strategy when a caller picks how one step is done. Choose State when the object switches its own behaviour as its lifecycle advances, Template Method when the overall outline is fixed and a few steps vary by subclass, and Command when you need to store, queue or undo requests. A strategy is often created by a factory that maps names to implementations.
14. Summary
- The strategy design pattern in Java wraps interchangeable algorithms behind one interface.
- Purpose: let the algorithm vary independently from the code that uses it.
- Components: Strategy, Concrete Strategies, Context, Client.
- The context delegates one step to its current strategy; the client chooses or swaps it.
- Used for discounts, sorting, payment routing, rejection policies and password encoding.
- Benefits: no conditional sprawl, runtime flexibility and testable rules.
- Keep strategies small and stateless, provide a default, and use lambdas for simple cases.
15. Java Design Pattern Interview Questions and Answers
Q1. How do you select a strategy at runtime in a Spring Boot application?
Make each strategy a bean implementing the same interface and inject a Map<String, Strategy>. Spring fills it with every implementation, keyed by bean name, so the request or configuration value selects one directly. Validate unknown keys and fall back to a default.
@Service
class PricingService {
private final Map<String, DiscountStrategy> strategies; // e.g. "percentageOff" -> bean
PricingService(Map<String, DiscountStrategy> strategies) { this.strategies = strategies; }
BigDecimal price(String campaign, BigDecimal subtotal) {
return strategies.getOrDefault(campaign, strategies.get("noDiscount")).apply(subtotal);
}
}Q2. When should a strategy be a lambda and when a full class?
Use a lambda or method reference for short, stateless rules, like most comparators. Use a class or record when the strategy has configuration (such as a percentage), several helper methods, its own dependencies, or needs a meaningful name in logs and tests.
Q3. How is Strategy different from Template Method?
Strategy uses composition: the context holds an object, and the whole algorithm can be swapped at runtime. Template Method uses inheritance: the base class fixes the outline, and a subclass fills in steps at compile time. If you need to change behaviour per request or tenant, Strategy fits; if the outline must be enforced and only a few steps vary, Template Method fits.
Q4. A shared strategy keeps a running counter in a field, and the numbers come out wrong under load. Why?
One strategy instance is shared by all request threads, so an unsynchronised field such as count++ loses updates. Make strategies stateless; if they need shared counters, use AtomicInteger or LongAdder, or keep per-request state in the call's parameters.
Q5. Different strategies need different inputs: one needs the cart, another the customer's tier. How do you design the interface?
Pass one context object, such as a PricingContext record holding the cart, the customer and the date, so every strategy reads what it needs and the method signature stays stable. Avoid adding parameters for each new strategy, which forces changes on all of them.