Strategy Design Pattern: Simplifying Software Design
Stop writing massive if-else statements. The Strategy Pattern allows you to swap algorithms at runtime. We explain it with a Payment Processing exampl
Abstract Algorithms
TLDR: The Strategy Pattern replaces giant
if-elseorswitchblocks with a family of interchangeable algorithm classes. Each strategy is a self-contained unit that can be swapped at runtime without touching the client code. The result: Open/Closed Principle compliance and dramatically easier testing.
๐ Stop Writing If-Else Hell: The Case for Strategy
Imagine a PaymentProcessor that handles Stripe, PayPal, Apple Pay, and crypto:
void pay(String type, int amount) {
if (type.equals("STRIPE")) {
// 50 lines of Stripe logic
} else if (type.equals("PAYPAL")) {
// 50 lines of PayPal logic
} else if (type.equals("BITCOIN")) {
// 50 lines of Bitcoin logic
}
// Adding "Apple Pay" means modifying this class โ wrong
}
The problem: every new payment method forces you to modify the core class, violating the Open/Closed Principle (open for extension, closed for modification). Tests become fragile and the class grows without bound.
The Strategy Pattern fixes this by extracting each algorithm into its own class.
๐ The Three Components of the Strategy Pattern
graph TD
A[Client: PaymentCheckout] --> B[Context: PaymentProcessor]
B --> C{Strategy interface: PaymentStrategy}
C --> D[StripeStrategy]
C --> E[PayPalStrategy]
C --> F[ApplePayStrategy]
| Component | Role | Example |
| Strategy (interface) | Defines the contract all algorithms must follow | PaymentStrategy.pay(int amount) |
| Concrete Strategy | Implements the algorithm | StripeStrategy, PayPalStrategy |
| Context | Holds a reference to the current strategy; delegates work to it | PaymentProcessor |
๐ Class Hierarchy: Strategy Pattern
classDiagram
class Context {
-strategy: PaymentStrategy
+setStrategy(s: PaymentStrategy)
+checkout(amount: int)
}
class PaymentStrategy {
<>
+pay(amount: int)
}
class StripeStrategy {
-apiKey: String
+pay(amount: int)
}
class PayPalStrategy {
-email: String
+pay(amount: int)
}
class CryptoStrategy {
-walletAddress: String
+pay(amount: int)
}
Context --> PaymentStrategy
PaymentStrategy <|.. StripeStrategy
PaymentStrategy <|.. PayPalStrategy
PaymentStrategy <|.. CryptoStrategy
โ๏ธ Full Implementation: Payment Processing Example
// 1. Strategy interface
interface PaymentStrategy {
void pay(int amount);
}
// 2. Concrete strategies
class StripeStrategy implements PaymentStrategy {
private String apiKey;
public StripeStrategy(String apiKey) { this.apiKey = apiKey; }
@Override
public void pay(int amount) {
System.out.printf("Charging $%d via Stripe (key: %s)%n", amount, apiKey);
// real Stripe API call here
}
}
class PayPalStrategy implements PaymentStrategy {
private String email;
public PayPalStrategy(String email) { this.email = email; }
@Override
public void pay(int amount) {
System.out.printf("Sending $%d to %s via PayPal%n", amount, email);
}
}
// 3. Context
class PaymentProcessor {
private PaymentStrategy strategy;
public PaymentProcessor(PaymentStrategy strategy) {
this.strategy = strategy;
}
// Swap strategy at runtime
public void setStrategy(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void checkout(int amount) {
strategy.pay(amount); // delegate entirely to the strategy
}
}
// 4. Client usage
public class Main {
public static void main(String[] args) {
PaymentProcessor processor = new PaymentProcessor(new StripeStrategy("sk_test_123"));
processor.checkout(99); // โ Charging $99 via Stripe
// Runtime swap: user switches to PayPal
processor.setStrategy(new PayPalStrategy("user@example.com"));
processor.checkout(49); // โ Sending $49 to user@example.com via PayPal
}
}
Adding a new payment method (e.g., ApplePayStrategy) requires zero changes to PaymentProcessor or any other existing class.
๐ง Deep Dive: How Strategy Enables Open/Closed Design
The Strategy Pattern achieves Open/Closed compliance through composition over inheritance. The Context holds an interface reference โ not a concrete type โ so any conforming object can be injected at runtime. This is a direct application of dependency inversion: high-level modules (Context) depend on abstractions (Strategy interface), not on low-level implementations. At runtime, the JVM dispatches pay() through a virtual method table โ the same call site, different behavior, zero branching logic in the caller.
๐ Runtime Strategy Switching Flow
The real power of the Strategy Pattern is runtime flexibility โ the ability to swap the algorithm in the middle of execution without restarting the application or recompiling a single line of existing code.
graph TD
A[Client Code] --> B[Context: PaymentProcessor created with StripeStrategy]
B --> C{User changes payment method?}
C -->|No| D[context.checkout called โ delegates to StripeStrategy.pay]
C -->|Yes| E[context.setStrategy called with new PayPalStrategy]
E --> F[context.checkout called โ delegates to PayPalStrategy.pay]
D --> G[Payment result returned]
F --> G
Notice that the PaymentProcessor context class appears exactly once in this diagram and never changes shape. All variation lives in the leaf nodes โ the concrete strategy objects. Adding CryptoStrategy is a new leaf, not a change to the graph.
When does runtime swapping occur in practice?
- A user changes their preferred payment method during checkout without reloading the page.
- A configuration flag switches the log-routing strategy per deployment environment (
FileLoggerin dev,CloudWatchLoggerin production). - A circuit breaker detects the primary pricing service is slow and activates a cached fallback pricing strategy.
- A nightly batch job switches the compression algorithm from speed-optimized (LZ4) to size-optimized (Zstd) during off-peak hours.
The pattern scales cleanly because every new use case only adds a new class โ it never mutates the infrastructure that calls it. This is why large codebases often contain dozens of strategy implementations for a single context: each one handles exactly one case, is tested independently, and never risks breaking the others.
๐ Real-World Applications: Where the Strategy Pattern Appears in Real Systems
| Domain | Strategy family | What changes at runtime |
| Sorting library | QuickSort, MergeSort, HeapSort | Algorithm chosen by input size or stability requirement |
| Compression | GzipStrategy, ZstdStrategy, LZ4Strategy | Chosen by latency vs. ratio trade-off |
| Authentication | JWTStrategy, OAuth2Strategy, ApiKeyStrategy | Chosen per API endpoint config |
| Pricing engine | PercentDiscountStrategy, FlatRateStrategy, TieredPricingStrategy | Chosen by customer tier |
| Log routing | FileLogger, CloudWatchLogger, StdoutLogger | Chosen by environment config |
โ๏ธ Trade-offs & Failure Modes: When to Use Strategy (and When Not To)
Use Strategy when:
- You have multiple algorithms for the same task that differ only in behavior.
- You need to swap algorithms at runtime based on context or configuration.
- You want to eliminate conditional logic that grows with each new algorithm.
- Each algorithm variant needs to be independently unit-testable.
Avoid Strategy when:
- You only have two variants that never change โ a simple
ifis cleaner. - The number of strategies is likely to stay at one โ the abstraction has no payoff.
- Performance is critical and the interface dispatch overhead is measurable.
| Trade-off | Detail |
| + OCP compliance | New strategies extend without modifying existing code |
| + Testability | Each strategy is a self-contained class with focused tests |
| + Runtime flexibility | Swap behavior without restarting or redeploying |
| โ Strategy count | Too many small strategy classes can clutter the package |
| โ Indirection | Client must know which strategies exist and how to configure them |
๐งญ Decision Guide: Strategy vs. Similar Patterns
| Pattern | Key difference |
| Strategy | Swaps algorithms for the same task at runtime |
| Template Method | Defines a skeleton algorithm in a base class; subclasses fill in specific steps |
| State | Swaps behavior based on internal state transitions (not just algorithm) |
| Command | Encapsulates an action (not just an algorithm) as an object with undo support |
๐ฏ What to Learn Next
- Low-Level Design (LLD) for Tic-Tac-Toe
- Low-Level Design Guide for Ride Booking
- The Ultimate Data Structures Cheat Sheet
๐งช Hands-On: Add a Crypto Payment Strategy
The best way to verify you understand the pattern is to extend the existing system without modifying any code you did not write. Follow these steps:
Task: Add a CryptoStrategy that accepts a wallet address and prints a simulated blockchain payment.
Step 1 โ Write the strategy class:
class CryptoStrategy implements PaymentStrategy {
private String walletAddress;
public CryptoStrategy(String walletAddress) {
this.walletAddress = walletAddress;
}
@Override
public void pay(int amount) {
System.out.printf("Sending %d USDC to wallet %s on-chain%n", amount, walletAddress);
// real blockchain call here
}
}
Step 2 โ Wire it into the client:
processor.setStrategy(new CryptoStrategy("0xAbCd...1234"));
processor.checkout(200);
// โ Sending 200 USDC to wallet 0xAbCd...1234 on-chain
Step 3 โ Verify the rule: Open PaymentProcessor.java. Confirm you made zero edits to it. You only added a new file. That is the Open/Closed Principle in action.
Challenge: Now add a SubscriptionStrategy that checks if the user has a monthly cap remaining before charging. Write a unit test that uses a mock PaymentStrategy to assert pay() is called exactly once with the correct amount. Notice how easy the mock is to write because PaymentStrategy is a single-method interface.
๐ ๏ธ Spring Boot: Wiring Strategy Beans with @Service and @Qualifier
Spring Boot's dependency injection is the production-grade way to implement the Strategy Pattern in Java โ instead of manually calling new StripeStrategy(...), you declare each strategy as a @Service bean and let Spring inject the correct one via @Qualifier or a Map<String, PaymentStrategy> at startup. This moves algorithm selection entirely into configuration, making strategies swappable without recompiling a single line of business logic.
Spring solves the lesson from this post's Lesson 2: inject strategies via dependency injection, not hard-coded new. Operators can switch the active strategy by changing a config value; the PaymentProcessor context class never sees a new keyword.
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.Map;
// โโ 1. Strategy interface โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
public interface PaymentStrategy {
void pay(int amount);
String name(); // used as the bean qualifier key
}
// โโ 2. Strategy beans: each is an independent @Service โโโโโโโโโโโโโโโโโโโโโโโ
@Service("stripe")
public class StripeStrategy implements PaymentStrategy {
public void pay(int amount) {
System.out.printf("Stripe: charging $%d via API%n", amount);
}
public String name() { return "stripe"; }
}
@Service("paypal")
public class PayPalStrategy implements PaymentStrategy {
public void pay(int amount) {
System.out.printf("PayPal: sending $%d%n", amount);
}
public String name() { return "paypal"; }
}
@Service("crypto")
public class CryptoStrategy implements PaymentStrategy {
public void pay(int amount) {
System.out.printf("Crypto: broadcasting %d USDC on-chain%n", amount);
}
public String name() { return "crypto"; }
}
// โโ 3. Context: Spring injects ALL strategies into a Map keyed by bean name โโโ
@Service
public class PaymentProcessor {
// Spring auto-populates this map: {"stripe" โ StripeStrategy, "paypal" โ ..., ...}
private final Map<String, PaymentStrategy> strategies;
@Autowired
public PaymentProcessor(Map<String, PaymentStrategy> strategies) {
this.strategies = strategies;
}
public void checkout(String method, int amount) {
PaymentStrategy strategy = strategies.get(method);
if (strategy == null) {
throw new IllegalArgumentException("Unknown payment method: " + method);
}
strategy.pay(amount); // zero if-else, zero switch
}
}
// โโ 4. REST controller that drives the context โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {
private final PaymentProcessor processor;
public CheckoutController(PaymentProcessor processor) {
this.processor = processor;
}
// POST /api/checkout?method=stripe&amount=99
@PostMapping
public String checkout(@RequestParam String method, @RequestParam int amount) {
processor.checkout(method, amount);
return "Payment of $" + amount + " via " + method + " processed.";
}
}
Adding a new payment method (e.g., ApplePayStrategy) requires creating one new @Service("applepay") class โ zero changes to PaymentProcessor, CheckoutController, or any existing strategy. This is exactly what the Open/Closed Principle demands.
For a full deep-dive on Spring dependency injection patterns and @Service/@Qualifier wiring, a dedicated follow-up post is planned.
๐ Lessons from Real-World Strategy Implementations
Applying the Strategy Pattern across production systems reveals a consistent set of lessons that are not obvious from toy examples.
Lesson 1 โ Name strategies by their algorithm, not their caller. StripePaymentStrategy is weaker than StripeStrategy. The caller context (Payment) belongs in the package name, not the class name. When you later reuse StripeStrategy for subscription billing, you'll be glad you named it cleanly.
Lesson 2 โ Inject strategies via dependency injection, not hard-coded new. In Spring, Guice, or CDI, strategies are beans. A @Qualifier annotation selects the correct one at boot time. This moves algorithm selection from code to configuration โ operators can switch strategies by changing a config value, not a deployment.
Lesson 3 โ Avoid stateful strategies. Each strategy instance should be stateless and thread-safe so the same object can be shared across concurrent requests. If a strategy must carry state (e.g., an API key or a rate limiter), use the constructor to inject it and make all fields final.
Lesson 4 โ Combine with Factory for cleaner client code. A PaymentStrategyFactory.forType(String type) method encapsulates the if-else that selects a strategy. This concentrates all algorithm-selection logic in one place, leaving the Context completely clean.
Lesson 5 โ The pattern reveals bad design elsewhere. If you find it hard to extract a strategy because its logic is intertwined with unrelated state, that is a Single Responsibility Principle violation in disguise โ the class is doing too many things at once. Refactoring to Strategy forces you to separate algorithm logic from the data it operates on, which is always the right direction.
๐ TLDR: Summary & Key Takeaways
- Strategy encapsulates interchangeable algorithms behind a common interface, enabling runtime swapping.
- The Context class holds a reference to the current strategy โ it delegates entirely rather than implementing logic.
- Adding a new strategy never requires modifying existing classes โ a direct implementation of the Open/Closed Principle.
- Strategy excels at replacing
if-elsegrowth patterns in algorithm-selection logic. - Each concrete strategy is independently unit-testable โ a major maintainability win.
๐ Practice Quiz
What design principle does the Strategy Pattern directly implement?
- A) Single Responsibility Principle
- B) Open/Closed Principle โ open for extension, closed for modification
- C) Liskov Substitution Principle
- D) Dependency Inversion Principle
Correct Answer: B โ Adding a new payment method requires creating a new
PaymentStrategyclass only; no existing class is modified.In the Strategy Pattern, which component holds the reference to the current algorithm object?
- A) The Strategy interface
- B) The Context class
- C) The Concrete Strategy
- D) The Client class
Correct Answer: B โ The
PaymentProcessorcontext holds aPaymentStrategyreference and delegates all work to it viastrategy.pay(amount).When is the Strategy Pattern a bad fit?
- A) When you need to unit-test each algorithm independently
- B) When there is only one algorithm variant that will never change
- C) When you need to swap behavior at runtime
- D) When algorithms need to be independently versioned
Correct Answer: B โ If only one algorithm exists and no new variants are expected, the interface and delegation add complexity with no payoff. A simple method is cleaner.
๐ Related Posts
- Low-Level Design Guide for Ride Booking Application
- Open/Closed Principle Explained
- Single Responsibility Principle Explained

Written by
Abstract Algorithms
@abstractalgorithms
More Posts

Adapting to Virtual Threads for Spring Developers
TLDR: Platform threads (one OS thread per request) max out at a few hundred concurrent I/O-bound requests. Virtual threads (JDK 21+) allow millions โ with zero I/O-blocking cost. Spring Boot 3.2 enables them with a single property. Avoid synchronized...

Java 8 to Java 25: How Java Evolved from Boilerplate to a Modern Language
TLDR: Java went from the most verbose mainstream language to one of the most expressive. Lambdas killed anonymous inner classes. Records killed POJOs. Virtual threads killed thread pools for I/O work.
Data Anomalies in Distributed Systems: Split Brain, Clock Skew, Stale Reads, and More
TLDR: Distributed systems produce anomalies not because the code is buggy โ but because physics makes it impossible to be perfectly consistent, available, and partition-tolerant simultaneously. Split brain, stale reads, clock skew, causality violatio...
Sharding Approaches in SQL and NoSQL: Range, Hash, and Directory-Based Strategies Compared
TLDR: Sharding splits your database across multiple physical nodes so no single machine carries all the data or absorbs all the writes. The strategy you choose โ range, hash, consistent hashing, or directory โ determines whether range queries stay ch...
