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
Intermediate
For developers with some experience. Builds on fundamentals.
Estimated read time: 11 min
AI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
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.
๐ Related Posts
- Low-Level Design Guide for Ride Booking Application
- Open/Closed Principle Explained
- Single Responsibility Principle Explained
Test Your Knowledge
Ready to test what you just learned?
AI will generate 4 questions based on this article's content.

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
Java 21 to 25: Virtual Threads, Pattern Matching, and Structured Concurrency
TLDR: Java 21 LTS makes virtual threads a production-ready replacement for bounded thread pools โ your newFixedThreadPool(200) can become newVirtualThreadPerTaskExecutor() and handle 10ร the concurrency with no architectural changes. Pattern switch w...
Java 14 to 17: Records, Sealed Classes, Text Blocks, and Pattern Matching
TLDR: Java 14โ17 ran a deliberate four-release preview-to-stable conveyor belt. Records replaced 50-line POJOs with one line. Text blocks ended escape-sequence chaos in multi-line strings. Sealed classes turned "please only subclass these types" comm...
NoSQL Partitioning: How Cassandra, DynamoDB, and MongoDB Split Data
TLDR: Every NoSQL database hides a partitioning engine behind a deceptively simple API. Cassandra uses a consistent hashing ring where a Murmur3 hash of your partition key selects a node โ virtual nodes (vnodes) make rebalancing smooth. DynamoDB mana...
Clock Skew and Causality Violations: Why Distributed Clocks Lie
TLDR: Physical clocks on distributed machines cannot be perfectly synchronized. NTP keeps them within tens to hundreds of milliseconds in normal conditions โ but under load, across datacenters, or after a VM pause, the drift can reach seconds. When s...
