The ticket says “add a loyalty multiplier: 5% extra off for gold members.” You open OrderProcessor, find the discount block, and add a fourth if. Percent-off still works. Flat-off still works. The Black Friday branch still works. You also just re-ran every promotion’s tests for a change that had nothing to do with them, and you picked up a merge conflict from whoever is adding buy-one-get-one on another branch.

That is Strategy’s entire complaint. From the series hub: an algorithm should be swappable without editing callers.

This is the first pattern post in the catalog. The three families, the shared Order / PaymentGateway / DiscountPolicy lab, and when patterns are cargo-cult live on the Design Patterns Roadmap. Here we only care about one growing if forest and the small interface that retires it.

The discount block that grows every campaign

Here is the pricing slice of OrderProcessor after three promotions. Nobody wrote this badly on purpose:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final OrderRepository orders;

    public OrderProcessor(PaymentGateway gateway, OrderRepository orders) {
        this.gateway = gateway;
        this.orders = orders;
    }

    public void process(Order order, String promoCode, boolean goldMember) {
        BigDecimal payable = order.total();

        if ("PERCENT10".equals(promoCode)) {
            payable = payable.multiply(new BigDecimal("0.90"));
        } else if ("FLAT50".equals(promoCode)) {
            payable = payable.subtract(new BigDecimal("50"));
            if (payable.signum() < 0) {
                payable = BigDecimal.ZERO;
            }
        } else if ("BLACKFRIDAY".equals(promoCode)) {
            payable = payable.multiply(new BigDecimal("0.70"));
        }

        if (goldMember) {
            payable = payable.multiply(new BigDecimal("0.95"));
        }

        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
    }
}

Now price the loyalty ticket — and the next three:

Add gold-member extra off     -> edit process(), re-test every promo
Add buy-one-get-one           -> another branch, another conflict
Change PERCENT10 to 12%       -> same file as the paid-order state machine
Test FLAT50 in isolation      -> construct an OrderProcessor, a gateway, a repository

Four costs, and none of them are about charging a card. The processor has become the owner of every pricing experiment marketing will ever run.

Note: The problem is not the if keyword. A two-branch choice that has not moved in two years is fine. The problem is a branch set that grows on someone else’s calendar, sitting inside code you cannot afford to break. That is the same axis Open-Closed names; Strategy is one common shape for that seam.

What Strategy actually is

Two parts, one promise:

PartJob
ContextOwns the workflow. Asks for a result. Does not know which algorithm ran.
StrategyOne interface, one implementation per algorithm.

The context depends on the interface. The implementations do not know the context exists. That direction is the whole pattern. If PercentOff imports OrderProcessor, you have not swapped an algorithm; you have created a cycle.

The Gang of Four name is easy to over-read. You do not need a class named Strategy. You need a verb the caller already understands — here, “turn this order into a payable amount”:

public interface DiscountPolicy {

    BigDecimal payable(Order order);
}

That is the seam. Everything else is an implementation.

One class per algorithm

Each branch of the old if becomes a type that can be tested without a gateway or a repository.

public final class NoDiscount implements DiscountPolicy {

    @Override
    public BigDecimal payable(Order order) {
        return order.total();
    }
}

public final class PercentOff implements DiscountPolicy {

    private final BigDecimal factor;

    public PercentOff(BigDecimal percentOff) {
        this.factor = BigDecimal.ONE.subtract(percentOff.movePointLeft(2));
    }

    @Override
    public BigDecimal payable(Order order) {
        return order.total().multiply(factor).setScale(2, RoundingMode.HALF_UP);
    }
}

public final class FlatOff implements DiscountPolicy {

    private final BigDecimal amount;

    public FlatOff(BigDecimal amount) {
        this.amount = amount;
    }

    @Override
    public BigDecimal payable(Order order) {
        BigDecimal payable = order.total().subtract(amount);
        return payable.signum() < 0 ? BigDecimal.ZERO : payable;
    }
}

BLACKFRIDAY is not a third kind of discount. It is new PercentOff(new BigDecimal("30")) with a different constructor argument. If the campaign ever grows a rule that percent-off cannot express — “30% off, but never below cost” — that is a new class. Until then, do not invent a type for a parameter.

Gold membership is a second algorithm that composes with the first. Composition is allowed; stuffing it back into process() is what we just left:

public final class StackedDiscount implements DiscountPolicy {

    private final DiscountPolicy first;
    private final DiscountPolicy then;

    public StackedDiscount(DiscountPolicy first, DiscountPolicy then) {
        this.first = first;
        this.then = then;
    }

    @Override
    public BigDecimal payable(Order order) {
        BigDecimal afterFirst = first.payable(order);
        Order repriced = new Order(order.id(), order.customerEmail(), order.items(), afterFirst);
        return then.payable(repriced);
    }
}

That wrapper is a cousin of Decorator. Use it when you truly stack independent rules. If “gold extra 5%” is a flag that only marketing toggles on one campaign, a PercentOff of 14.5% may be the honest model. Do not build a pipeline for a product that has one discount at a time.

The processor stops knowing the math

OrderProcessor takes the policy the same way it already takes a PaymentGateway — as a constructor argument, not as a string it interprets:

public class OrderProcessor {

    private final PaymentGateway gateway;
    private final DiscountPolicy discount;
    private final OrderRepository orders;

    public OrderProcessor(
            PaymentGateway gateway, DiscountPolicy discount, OrderRepository orders) {
        this.gateway = gateway;
        this.discount = discount;
        this.orders = orders;
    }

    public void process(Order order) {
        BigDecimal payable = discount.payable(order);
        PaymentResult result = gateway.charge(order, payable);
        if (!result.approved()) {
            throw new PaymentDeclinedException(order.id(), result.failureReason());
        }
        orders.markPaid(order.id(), result.reference());
    }
}

The promo code still has to become an object. That translation belongs at the edge — a checkout controller, a config class, or a tiny factory — not inside the paid-order workflow:

public final class DiscountPolicies {

    public static DiscountPolicy from(String promoCode, boolean goldMember) {
        DiscountPolicy promo = switch (promoCode) {
            case "PERCENT10" -> new PercentOff(new BigDecimal("10"));
            case "FLAT50" -> new FlatOff(new BigDecimal("50"));
            case "BLACKFRIDAY" -> new PercentOff(new BigDecimal("30"));
            case null, default -> new NoDiscount();
        };
        if (!goldMember) {
            return promo;
        }
        return new StackedDiscount(promo, new PercentOff(new BigDecimal("5")));
    }
}

“You just moved the switch” is the fair objection. Yes. The difference is which file owns it. DiscountPolicies is allowed to change every campaign. OrderProcessor is not. Adding buy-one-get-one becomes a new class plus one case in the map — and zero edits to the charge-and-persist path.

Note: Keep the mapper dumb. The moment from starts charging cards, writing SQL, or asking “if it’s Black Friday, skip fraud checks,” the forest has grown back. Selection is data. Policy is process. Math is payable.

What the diff looks like now

Same feature request, both designs:

Before — add buy-one-get-one
  M OrderProcessor.java      new branch inside charge-and-persist
  M OrderProcessorTest.java  existing tests re-run, some rewritten

After — add buy-one-get-one
  A BuyOneGetOne.java        new class, existing callers untouched
  A BuyOneGetOneTest.java    new test, no gateway, no repository
  M DiscountPolicies.java    one case in the mapper

One added file plus one line of selection. OrderProcessor is closed against “a new promotion appears” and still fully open to editing when the workflow changes — a refund step, a fraud check, a second charge attempt. Those belong in process, because they are not discount algorithms.

Proving it with a fake policy

The seam pays a second dividend immediately: the processor becomes testable without inventing promo codes.

class FixedDiscount implements DiscountPolicy {

    private final BigDecimal payable;

    FixedDiscount(BigDecimal payable) {
        this.payable = payable;
    }

    @Override
    public BigDecimal payable(Order order) {
        return payable;
    }
}

A unit test can now assert that a declined charge does not mark the order paid, using a payable amount that never went through percent math:

@Test
void declinedChargeDoesNotMarkPaid() {
    FakePaymentGateway gateway = FakePaymentGateway.alwaysDeclined("card_declined");
    RecordingRepository orders = new RecordingRepository();
    OrderProcessor processor = new OrderProcessor(
            gateway, new FixedDiscount(new BigDecimal("42.00")), orders);

    assertThrows(PaymentDeclinedException.class, () -> processor.process(order()));
    assertTrue(orders.markPaidCalls().isEmpty());
}

And PercentOff can be tested with a record and an assertion — no processor, no network:

@Test
void tenPercentOffRoundsHalfUp() {
    DiscountPolicy policy = new PercentOff(new BigDecimal("10"));
    Order order = new Order("o-1", "a@b.com", List.of(), new BigDecimal("19.99"));

    assertEquals(new BigDecimal("17.99"), policy.payable(order));
}

If a test for charging needs a real discount formula, you have mixed two reasons to change. Split the tests the same way you split the types.

When Strategy is the wrong move

The SOLID series already named the failure mode: a TaxStrategy in a company that sells in one country. The same test applies here.

Skip the interface when:

  • There is one algorithm and no second on the roadmap. order.total() is a policy. Wrapping it in NoDiscount “for consistency” is two files and a constructor argument that never varies.
  • The variation is a parameter, not a formula. 10% and 30% are not two strategies. They are PercentOff with two arguments.
  • The caller must know which one ran. If process starts with if (discount instanceof FlatOff) to skip a coupon audit, the swap was a lie. Either extend the interface with an honest method (boolean requiresCouponAudit()) or keep the branch in a place that is allowed to know.
  • You are reaching for the name to look designed. Review comments that say “this should be a Strategy” without naming the second algorithm are unactionable.

The healthy trigger is a second formula you can paste from a ticket. Percent vs flat vs “cheapest line item free” are three formulas. Percent vs percent-on-Fridays is a parameter, or a Clock you pass in — not a new type.

Strategy also is not Template Method. Template Method freezes the skeleton in a superclass and lets subclasses fill in steps. Strategy swaps the whole algorithm through an interface the context already holds. If you need both a stable sequence and a swappable step, start with Strategy for the step; do not inherit your way into it. Template Method gets its own post later in this catalog.

Cheat sheet

Context     OrderProcessor          asks discount.payable(order), does not name PercentOff
Strategy    DiscountPolicy          one method, one job
Variants    PercentOff, FlatOff     one class per formula, not per campaign name
Selection   DiscountPolicies.from   allowed to change every week; lives at the edge

Trigger to apply: a second formula is on a ticket or already in an if/else
Trigger to stop:  one formula, or two values of the same formula
Scoreboard:       new promotion = new file; OrderProcessor tests do not change

Do:

  • Name the interface after the verb the caller already uses (payable, charge, route).
  • Pass the strategy in. Do not look it up from a singleton inside the context.
  • Test each algorithm without the context; test the context with a fake algorithm.
  • Compose independent rules only when the product actually stacks them.

Don’t:

  • Extract a Strategy for an if that has two stable, finished cases.
  • Put vendor SDKs, SQL, or HTTP inside a discount implementation — that is a different seam (PaymentGateway, a repository).
  • Switch on instanceof in the context after introducing the interface.
  • Create XxxStrategy / XxxStrategyImpl with a single implementer and no test double asking for it.

Wrap-up

Strategy is a small interface whose implementations are algorithms, and a context that refuses to name them. The discount forest in OrderProcessor was expensive because every campaign edited the charge-and-persist path. Moving payable behind DiscountPolicy makes the next promotion an added class, keeps the math unit-testable, and leaves process free to change when the workflow changes.

If the next pain is “callers still say new PercentOff in six controllers,” that is Factory Method — who is allowed to construct the strategy.

Next optional step in the series Move construction out of the callers that still say new. Factory Method: Let Subclasses Decide Which Object to Build