04-behavioral-patterns

Strategy Pattern: Swapping Algorithms at Runtime

Replace sprawling if/else chains with interchangeable algorithm objects — the pattern behind pricing engines, sort comparators, and payment routing.

August 11, 2026
lldbehavioralstrategytemplate-methodalgorithminterchangeablehooks

Strategy Pattern

Define a family of algorithms, encapsulate each one as an object, and make them interchangeable at runtime. Strategy is the pattern you reach for the moment you notice a method's behavior needs to vary by type, configuration, or user choice — and that variation is decided by the caller, not baked into the object itself.

It is composition doing the job inheritance can't: a PaymentProcessor doesn't need a different class per payment method, it needs a different PaymentStrategy object plugged in.

💡

Strategy is one of the most-used patterns in interview LLD rounds precisely because it maps so directly onto real requirements: "the fee calculation differs by vehicle type," "the discount differs by customer tier," "the spot-assignment rule differs by lot policy." Any sentence with the word "differs by" is a Strategy candidate.


1. The Problem: Conditional Sprawl

Every new variant of a behavior triggers an edit to the same method. The method grows, its test surface grows, and unrelated variants sit inches apart in the same file — a single typo in the PAYPAL branch can break CREDIT_CARD.

java
// VIOLATION: one method, growing forever, coupled to every payment method's internals
class PaymentProcessor {
    void pay(String method, double amount, String cardNumber, String paypalEmail, String upiId) {
        if (method.equals("CREDIT_CARD")) {
            System.out.println("Charging card " + cardNumber + " for $" + amount);
            // validate Luhn checksum, call card network...
        } else if (method.equals("PAYPAL")) {
            System.out.println("Redirecting to PayPal for " + paypalEmail + ", $" + amount);
            // call PayPal SDK...
        } else if (method.equals("UPI")) {
            System.out.println("Charging UPI ID " + upiId + " for $" + amount);
            // call UPI gateway...
        } else {
            throw new IllegalArgumentException("Unsupported method: " + method);
        }
    }
}

Three concrete problems compound here:

  1. OCP violation — adding CRYPTO means editing a tested, working method.
  2. Parameter bloat — the method signature carries fields relevant to only one branch each (cardNumber is dead weight for a PayPal payment).
  3. Untestable in isolation — you cannot unit-test the credit-card logic without also compiling/loading the PayPal and UPI code paths.
⚠️

This is the same signal called out in SOLID Principles for OCP: an if/else or switch chain keyed off a type tag that keeps growing. Strategy is the concrete refactor OCP is pointing you toward.


2. Structure

The context (PaymentProcessor) never knows which concrete strategy it holds — only that it satisfies the PaymentStrategy contract. Swapping behavior is a one-line assignment, not a code change.

Three participants, always:

RoleResponsibilityIn the example
Strategy (interface)Declares the algorithm's contractPaymentStrategy
ConcreteStrategyOne interchangeable implementationCreditCardStrategy, PayPalStrategy, UpiStrategy
ContextHolds a strategy reference, delegates to it, never branches on typePaymentProcessor

3. Full Implementation

java
// The Strategy interface — the family of interchangeable algorithms
interface PaymentStrategy {
    void pay(double amount);
}
 
// Each concrete strategy owns exactly the state and logic it needs
class CreditCardStrategy implements PaymentStrategy {
    private final String cardNumber;
    private final String cvv;
 
    CreditCardStrategy(String cardNumber, String cvv) {
        this.cardNumber = cardNumber;
        this.cvv = cvv;
    }
 
    @Override
    public void pay(double amount) {
        // validate, call card network, etc.
        System.out.printf("Charged $%.2f to card ending %s%n",
            amount, cardNumber.substring(cardNumber.length() - 4));
    }
}
 
class PayPalStrategy implements PaymentStrategy {
    private final String email;
 
    PayPalStrategy(String email) { this.email = email; }
 
    @Override
    public void pay(double amount) {
        System.out.printf("Charged $%.2f via PayPal (%s)%n", amount, email);
    }
}
 
class UpiStrategy implements PaymentStrategy {
    private final String upiId;
 
    UpiStrategy(String upiId) { this.upiId = upiId; }
 
    @Override
    public void pay(double amount) {
        System.out.printf("Charged $%.2f via UPI (%s)%n", amount, upiId);
    }
}
 
// The Context: holds a strategy reference, delegates, knows nothing about internals
class PaymentProcessor {
    private PaymentStrategy strategy;
 
    PaymentProcessor(PaymentStrategy strategy) {
        this.strategy = strategy;
    }
 
    // Runtime swap — this is the part inheritance cannot give you
    void setStrategy(PaymentStrategy strategy) {
        this.strategy = strategy;
    }
 
    void checkout(double amount) {
        strategy.pay(amount);
    }
}
 
class Demo {
    public static void main(String[] args) {
        PaymentProcessor processor = new PaymentProcessor(
            new CreditCardStrategy("4111111111111111", "123"));
        processor.checkout(49.99);
 
        // Same cart, different payment method chosen at checkout — no new subclass needed
        processor.setStrategy(new UpiStrategy("arvind@okhdfcbank"));
        processor.checkout(49.99);
    }
}

Adding CryptoStrategy later means writing one new class. PaymentProcessor, CreditCardStrategy, PayPalStrategy, and every existing test remain untouched.


4. Runtime Sequence

The key detail: Processor calls strategy.pay(amount) identically in both calls. Nothing in PaymentProcessor changes between the two checkout() invocations — only which object strategy points to.


5. Strategy + Factory: Choosing the Right Strategy

Strategy solves "how do I execute the algorithm once I have it." It doesn't solve "how do I decide which one to instantiate." That's a natural pairing with Factory Method — the factory owns the if/else (or a lookup map), but it's a single, contained decision point, not scattered across every method that needs the behavior.

java
class PaymentStrategyFactory {
    private static final Map<String, Function<PaymentDetails, PaymentStrategy>> CREATORS = Map.of(
        "CREDIT_CARD", d -> new CreditCardStrategy(d.cardNumber(), d.cvv()),
        "PAYPAL",      d -> new PayPalStrategy(d.paypalEmail()),
        "UPI",         d -> new UpiStrategy(d.upiId())
    );
 
    static PaymentStrategy create(String method, PaymentDetails details) {
        Function<PaymentDetails, PaymentStrategy> creator = CREATORS.get(method);
        if (creator == null) throw new IllegalArgumentException("Unsupported method: " + method);
        return creator.apply(details);
    }
}

Now adding CRYPTO means adding one map entry in one place — the conditional still exists, but it's been collapsed to a single sanctioned location instead of duplicated across every call site that needs to pick a payment method.

This factory-creates / strategy-executes split shows up constantly: DiscountStrategyFactory picking a DiscountStrategy, CompressionStrategyFactory picking a CompressionStrategy based on file extension, RouteStrategyFactory picking a RoutingStrategy based on vehicle type. The shape repeats because the problem repeats.


6. A Second Worked Example: Enum-Backed Strategy

Not every Strategy needs its own top-level class hierarchy. When the number of variants is small, closed, and each implementation is short, a Java enum implementing the interface directly avoids one file per strategy while keeping every OCP benefit:

java
interface DiscountStrategy {
    double apply(double amount);
}
 
enum CustomerTierDiscount implements DiscountStrategy {
    REGULAR {
        public double apply(double amount) { return amount; }
    },
    PREMIUM {
        public double apply(double amount) { return amount * 0.9; }
    },
    VIP {
        public double apply(double amount) { return amount * 0.8; }
    };
}
 
class Cart {
    double checkout(double amount, DiscountStrategy discount) {
        return discount.apply(amount);
    }
}
 
// Usage: Cart.checkout(100.0, CustomerTierDiscount.VIP) -> 80.0

This is still Strategy — Cart never branches on tier — but it trades "any number of files, open to arbitrary new implementations" for "one file, closed to a fixed enum set." Use it when the variant set truly is closed (e.g., a fixed set of customer tiers defined by the business), and reach for the interface-per-class version when new variants are expected to come from outside the enum's control (e.g., plugin-style payment gateways).


7. Testing Strategies in Isolation

The core benefit of Strategy shows up most clearly in test code: each strategy is testable without the context, and the context is testable with a trivial stub strategy.

java
class CreditCardStrategyTest {
    @Test
    void chargesTheProvidedCard() {
        CreditCardStrategy strategy = new CreditCardStrategy("4111111111111111", "123");
        // no PaymentProcessor, no PayPal/UPI code paths loaded — pure, isolated unit test
        assertDoesNotThrow(() -> strategy.pay(49.99));
    }
}
 
class PaymentProcessorTest {
    @Test
    void delegatesToWhicheverStrategyIsSet() {
        AtomicReference<Double> capturedAmount = new AtomicReference<>();
        PaymentStrategy spyStrategy = amount -> capturedAmount.set(amount);
 
        PaymentProcessor processor = new PaymentProcessor(spyStrategy);
        processor.checkout(25.0);
 
        assertEquals(25.0, capturedAmount.get());
        // PaymentProcessor's own logic is tested without any real payment strategy at all
    }
}

A context class that depends on a Strategy interface is trivially testable with a lambda or a hand-rolled test double — no mocking framework required, because PaymentStrategy is already a single-method functional interface.


8. Common Pitfalls

Pitfall 1 — Strategy objects that leak mutable, shared state. If a single CreditCardStrategy instance is reused across concurrent requests and accidentally stores per-request mutable fields (e.g., lastChargedAmount), you get cross-request data corruption under load. Keep strategies either stateless or instantiated per request.

java
// BAD: mutable field makes this strategy unsafe to share across threads
class UnsafeDiscountStrategy implements DiscountStrategy {
    private double lastAppliedAmount; // shared, mutated per call — race condition
 
    public double apply(double amount) {
        lastAppliedAmount = amount * 0.9; // written concurrently by multiple threads
        return lastAppliedAmount;
    }
}

Pitfall 2 — Reintroducing the conditional one level up. Moving the if/else from inside the algorithm to inside the selection of which strategy to use doesn't eliminate it — it relocates it. That's fine (and expected — see the Factory section above), but don't mistake "I used Strategy" for "I eliminated all conditionals from the system." The goal is collapsing the conditional to one sanctioned place, not zero places.

Pitfall 3 — A "God Strategy" interface. If PaymentStrategy grows to include pay(), refund(), verify(), generateReceipt(), and sendNotification(), you've reintroduced ISP violations inside your Strategy interface — some implementations will be forced to stub out methods they don't need. Keep each Strategy interface to the one behavior that actually varies.


9. When to Use vs. When It's Overkill

Use Strategy whenSkip it when
You have 2+ real, interchangeable algorithm variants today, or a strong signal more are comingThere is exactly one algorithm and no plausible second variant
The choice of algorithm needs to change at runtime, per request/instanceThe choice is fixed at compile time and never varies per call
You want to unit-test each algorithm in isolation, without the others compiled inThe branches are trivial one-liners with no independent logic to test
The if/else is keyed off a type/enum that grows over timeThe enumeration is closed and stable (e.g., 7 days of the week)
⚠️

Over-applying Strategy: wrapping a two-line ternary (isPremium ? 0.9 : 1.0) in a DiscountStrategy interface with two classes adds a file, an interface, and an indirection for a rule that will plausibly never grow a third case. Strategy earns its cost when variation is real and recurring, not for every conditional you see.


10. Strategy vs. Template Method

Both solve "vary the algorithm," but at opposite ends of the inheritance-vs-composition spectrum:

StrategyTemplate Method
MechanismComposition — the algorithm object is injectedInheritance — subclass overrides specific steps
When is the variant chosenRuntime — swap freely, even mid-lifetimeCompile-time — fixed once the subclass is chosen
What variesThe entire algorithm is swapped as one unitIndividual steps of a fixed skeleton vary
Relationship to baseNo shared skeleton — each strategy is independentShared skeleton in the base class controls the flow
CouplingLoose — context depends only on the interfaceTighter — subclass is coupled to base class internals

A useful rule of thumb: if you find yourself wanting to override just one step of a larger, otherwise-fixed process, you want Template Method. If you want to swap the entire algorithm as a single interchangeable unit, you want Strategy. See Template Method Pattern for the full contrast with a worked example.


11. Real-World / Production Examples

java.util.Comparator — the canonical JDK Strategy. Collections.sort(list, comparator) takes the sorting criterion as an injected strategy; the sort algorithm itself never changes.

java
list.sort(Comparator.comparing(Employee::getSalary).reversed());
list.sort(Comparator.comparing(Employee::getName));
// Same sort() call, same list — the comparison STRATEGY is what varies.

Spring's PasswordEncoderBCryptPasswordEncoder, Pbkdf2PasswordEncoder, NoOpPasswordEncoder are all interchangeable strategies wired in via configuration, injected wherever PasswordEncoder is a dependency.

Payment gateways — Stripe/Razorpay/PayPal SDK wrappers behind a common PaymentGateway interface, chosen per merchant or per region — structurally identical to the example built in this guide.

Compression — a CompressionStrategy with GzipStrategy, ZstdStrategy, Lz4Strategy chosen based on latency vs. ratio trade-offs at upload time, often selected by a factory keyed on content type or client capability headers.

Route/pricing engines — ride-hailing surge pricing picks a PricingStrategy (SurgePricingStrategy, FlatRateStrategy) based on demand signals, without touching the booking flow.

Validation pipelines — a ValidationStrategy chosen by field type (EmailValidationStrategy, PhoneValidationStrategy) plugged into a generic form validator, so adding a new field type never touches the validator's core loop.


12. Strategy in Spring: Injecting a Map of Beans

In a Spring application, the Factory-picks-a-Strategy step from Section 5 is often replaced entirely by autowiring all implementations of an interface into a Map<String, Strategy>, keyed by bean name — Spring does the "which one" bookkeeping for you.

java
interface PaymentStrategy {
    void pay(double amount);
}
 
@Component("creditCard")
class CreditCardStrategy implements PaymentStrategy {
    public void pay(double amount) { /* ... */ }
}
 
@Component("paypal")
class PayPalStrategy implements PaymentStrategy {
    public void pay(double amount) { /* ... */ }
}
 
@Service
class PaymentService {
    private final Map<String, PaymentStrategy> strategies;
 
    // Spring injects every PaymentStrategy bean, keyed by bean name — no factory
    // class to hand-maintain, no if/else anywhere in this service.
    PaymentService(Map<String, PaymentStrategy> strategies) {
        this.strategies = strategies;
    }
 
    void checkout(String method, double amount) {
        PaymentStrategy strategy = strategies.get(method);
        if (strategy == null) throw new IllegalArgumentException("Unsupported: " + method);
        strategy.pay(amount);
    }
}

Adding CryptoStrategy now means adding one @Component("crypto")-annotated class — PaymentService is never edited, and there is no factory class to keep in sync with the set of implementations. This is the idiomatic Spring version of Section 5's PaymentStrategyFactory: the "factory" is the Spring container itself.

💡

This only works cleanly when strategies are stateless singletons (Spring beans default to singleton scope) — which reinforces the thread-safety pitfall from Section 8. If a strategy needs per-request state, inject that state as a method parameter (as the examples in this guide do with amount) rather than storing it on the bean.


Interview Questions

  • How does Strategy satisfy the Open/Closed Principle? Walk through what changes (and what doesn't) when a new strategy is added.
  • What's the difference between Strategy and simply passing a function/lambda? When would you still prefer a named interface over java.util.function.Function?
  • Contrast Strategy with Template Method. Which one would you use to vary "which sort comparator" vs. "which steps of order processing run"?
  • Why does Strategy make unit testing easier than an equivalent if/else chain in one method?
  • Describe how Strategy and Factory Method compose. What problem does each one solve, and why doesn't Strategy alone eliminate all conditionals from the system?
  • Give a production example (not payments) where Strategy is used to swap behavior based on configuration rather than user choice.
  • When would introducing a Strategy interface be premature abstraction? What's the cost you're trading against flexibility?
  • How would you make a Strategy implementation stateless and thread-safe so a single instance can be shared across concurrent requests?
  • When is an enum-backed Strategy preferable to one interface implemented by N separate classes? What do you give up by choosing the enum?
  • In a Spring app, how would you let the container assemble a Map<String, PaymentStrategy> automatically instead of hand-writing a factory? What bean scope assumption does this rely on?

Quick Reference

QuestionAnswer
What does Strategy encapsulate?An entire interchangeable algorithm, chosen by the caller
What's the extension point?A new class implementing the Strategy interface
When is the variant selected?At runtime, any time before (or between) calls
What GoF category?Behavioral
Closest sibling patternTemplate Method (inheritance, not composition)
Typical JDK exampleComparator<T>