All Posts

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 AlgorithmsAbstract Algorithms
ยทยท11 min read
Cover Image for Strategy Design Pattern: Simplifying Software Design
Share
AI Share on X / Twitter
AI Share on LinkedIn
Copy link

TLDR: The Strategy Pattern replaces giant if-else or switch blocks 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]
ComponentRoleExample
Strategy (interface)Defines the contract all algorithms must followPaymentStrategy.pay(int amount)
Concrete StrategyImplements the algorithmStripeStrategy, PayPalStrategy
ContextHolds a reference to the current strategy; delegates work to itPaymentProcessor

๐Ÿ“Š 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 (FileLogger in dev, CloudWatchLogger in 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

DomainStrategy familyWhat changes at runtime
Sorting libraryQuickSort, MergeSort, HeapSortAlgorithm chosen by input size or stability requirement
CompressionGzipStrategy, ZstdStrategy, LZ4StrategyChosen by latency vs. ratio trade-off
AuthenticationJWTStrategy, OAuth2Strategy, ApiKeyStrategyChosen per API endpoint config
Pricing enginePercentDiscountStrategy, FlatRateStrategy, TieredPricingStrategyChosen by customer tier
Log routingFileLogger, CloudWatchLogger, StdoutLoggerChosen 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 if is 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-offDetail
+ OCP complianceNew strategies extend without modifying existing code
+ TestabilityEach strategy is a self-contained class with focused tests
+ Runtime flexibilitySwap behavior without restarting or redeploying
โˆ’ Strategy countToo many small strategy classes can clutter the package
โˆ’ IndirectionClient must know which strategies exist and how to configure them

๐Ÿงญ Decision Guide: Strategy vs. Similar Patterns

PatternKey difference
StrategySwaps algorithms for the same task at runtime
Template MethodDefines a skeleton algorithm in a base class; subclasses fill in specific steps
StateSwaps behavior based on internal state transitions (not just algorithm)
CommandEncapsulates an action (not just an algorithm) as an object with undo support

๐ŸŽฏ What to Learn Next


๐Ÿงช 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-else growth patterns in algorithm-selection logic.
  • Each concrete strategy is independently unit-testable โ€” a major maintainability win.

๐Ÿ“ Practice Quiz

  1. 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 PaymentStrategy class only; no existing class is modified.

  2. 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 PaymentProcessor context holds a PaymentStrategy reference and delegates all work to it via strategy.pay(amount).

  3. 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.



Abstract Algorithms

Written by

Abstract Algorithms

@abstractalgorithms