An online store has to calculate shipping fees in several ways: standard delivery, express delivery, free shipping for orders of 500,000 VND or more, and next week marketing will come up with a fourth. If this logic lives in a giant if/else if chain inside CheckoutService, every new rule forces you to edit a class that already works. The Strategy pattern pulls each algorithm out into a replaceable object, so the calling class only knows "there is a way to calculate the fee" and not which one. This article writes the Strategy pattern two ways, the traditional GoF class style and the Java 8+ lambda style, runs both on JDK 21 (compiled with --release 17), and then discusses where lambdas help and where they hurt.

The previous article in the series is Bridge and Flyweight pattern: separating abstraction and sharing state, where we noted that Bridge and Strategy have the same structure. If you need a refresher on the underlying principles, SOLID principles in Java with practical examples explains why Open/Closed is the main motivation for this pattern.

The problem the Strategy pattern solves: if/else chains for algorithm variants

Code that does not use the Strategy pattern usually looks like this: it receives a shippingType string, branches with a switch, and each branch computes the fee with its own formula. It works, but it has three problems that the Strategy pattern exists to solve. First, adding a rule means editing the old class, which violates Open/Closed. Second, every formula is piled into one place, which makes each formula hard to test on its own. Third, choosing the formula (by the customer, by configuration, by a promotion) is mixed with doing the calculation.

According to refactoring.guru, the Strategy pattern is a behavioral pattern: put each algorithm in its own class so you can swap them without changing the code that uses them. It fits when you need to switch algorithm variants at runtime, when many classes differ only in one behavior, or when you want to separate business logic from algorithm details.

Structure of the GoF Strategy pattern

RoleResponsibilityIn the shipping fee example
StrategyInterface that declares the single method the context callsShippingStrategy.fee(Order)
Concrete StrategyEach class implements one algorithm variantStandardShipping, ExpressShipping, FreeShippingOver500k
ContextHolds a reference to a strategy and delegates to itCheckout
ClientPicks a concrete strategy and hands it to the contextmain

This is the pure GoF implementation of the Strategy pattern, one class per strategy:

import java.util.List;

public class StrategyClassic {

    interface ShippingStrategy {
        long fee(Order order);
    }

    record Order(String id, long amount, double weightKg, String region) { }

    static final class StandardShipping implements ShippingStrategy {
        public long fee(Order o) { return 20_000 + Math.round(Math.ceil(o.weightKg())) * 5_000; }
    }
    static final class ExpressShipping implements ShippingStrategy {
        public long fee(Order o) { return 45_000 + Math.round(Math.ceil(o.weightKg())) * 12_000; }
    }
    static final class FreeShippingOver500k implements ShippingStrategy {
        public long fee(Order o) { return o.amount() >= 500_000 ? 0 : new StandardShipping().fee(o); }
    }

    /** Context: holds the strategy and delegates to it. */
    static final class Checkout {
        private ShippingStrategy strategy;
        Checkout(ShippingStrategy s) { this.strategy = s; }
        void setStrategy(ShippingStrategy s) { this.strategy = s; }
        long total(Order o) { return o.amount() + strategy.fee(o); }
    }

    public static void main(String[] args) {
        Order small = new Order("ORD-1", 300_000, 1.2, "HN");
        Order big = new Order("ORD-2", 800_000, 3.5, "HCM");
        List<ShippingStrategy> all = List.of(new StandardShipping(), new ExpressShipping(), new FreeShippingOver500k());
        for (ShippingStrategy s : all) {
            Checkout c = new Checkout(s);
            System.out.printf("%-22s %s=%,d  %s=%,d%n", s.getClass().getSimpleName(),
                    small.id(), c.total(small), big.id(), c.total(big));
        }
    }
}

Actual output (java -cp out StrategyClassic). Checking by hand: order ORD-1 costs 300,000 and weighs 1.2 kg, rounded up to 2 kg, so the standard fee is 20,000 + 2 × 5,000 = 30,000 and the total is 330,000:

StandardShipping       ORD-1=330,000  ORD-2=840,000
ExpressShipping        ORD-1=369,000  ORD-2=893,000
FreeShippingOver500k   ORD-1=330,000  ORD-2=800,000

This approach is correct and clear, but when a Strategy pattern has only one method, three classes plus an interface is a lot of ceremony. That is exactly why, in modern Java, the Strategy pattern is usually written with lambdas.

Strategy pattern with lambdas and functional interfaces

ShippingStrategy has exactly one abstract method, which makes it a functional interface. According to the Java SE 17 @FunctionalInterface documentation, such an interface has exactly one abstract method; default methods and methods that override Object do not count. A lambda or method reference can be assigned to any functional interface thanks to target typing: the compiler infers the lambda's type from its context. refactoring.guru itself notes in its list of drawbacks that in a language with functional features you can replace strategy objects with lambdas, avoiding the extra classes and interfaces.

Here is the lambda version of the Strategy pattern for the same problem. Note that @FunctionalInterface is not technically required, but it makes the compiler report an error if someone accidentally adds a second abstract method:

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Function;
import java.util.function.ToLongFunction;

public class StrategyLambda {

    record Order(String id, long amount, double weightKg, String region) { }

    /** Keep a named interface when you want to document the business meaning; lambdas can still be assigned to it. */
    @FunctionalInterface
    interface ShippingStrategy {
        long fee(Order order);

        // default methods let you "compose" strategies, like Comparator.thenComparing
        default ShippingStrategy withCap(long max) { return o -> Math.min(max, fee(o)); }
        default ShippingStrategy plusSurcharge(long extra) { return o -> fee(o) + extra; }
    }

    static final ShippingStrategy STANDARD = o -> 20_000 + (long) Math.ceil(o.weightKg()) * 5_000;
    static final ShippingStrategy EXPRESS  = o -> 45_000 + (long) Math.ceil(o.weightKg()) * 12_000;
    static final ShippingStrategy FREE_OVER_500K = o -> o.amount() >= 500_000 ? 0 : STANDARD.fee(o);

    static long total(Order o, ShippingStrategy s) { return o.amount() + s.fee(o); }

In the lambda form of the Strategy pattern, the three constants STANDARD, EXPRESS and FREE_OVER_500K replace three classes. Adding a new rule to the Strategy pattern is just one more line. The next block is the rest of the same StrategyLambda class (the previous block and this one join into the file code/s6-12/src/StrategyLambda.java). The main method demonstrates six usages, from a registry keyed by configuration to composing strategies:

    public static void main(String[] args) {
        Order small = new Order("ORD-1", 300_000, 1.2, "HN");
        Order big = new Order("ORD-2", 800_000, 3.5, "HCM");

        // 1) Pick a strategy by configuration key: a Map replaces an if/else chain or switch
        Map<String, ShippingStrategy> registry = new LinkedHashMap<>();
        registry.put("standard", STANDARD);
        registry.put("express", EXPRESS);
        registry.put("free500k", FREE_OVER_500K);
        registry.forEach((name, s) ->
                System.out.printf("%-9s %s=%,d  %s=%,d%n", name, small.id(), total(small, s), big.id(), total(big, s)));

        // 2) A strategy written inline, no new class needed
        long freeHanoi = total(small, o -> o.region().equals("HN") ? 0 : 30_000);
        System.out.printf("Inline lambda (free inside Hanoi): %,d%n", freeHanoi);

        // 3) Method reference: the strategy is an existing static method
        ShippingStrategy flat = StrategyLambda::flatFee;
        System.out.printf("Method reference (flat fee): %,d%n", total(big, flat));

        // 4) Compose strategies with default methods
        ShippingStrategy expressCapped = EXPRESS.withCap(80_000);
        System.out.printf("Express capped at 80,000: fee ORD-1=%,d, ORD-2=%,d (uncapped: %,d)%n",
                expressCapped.fee(small), expressCapped.fee(big), EXPRESS.fee(big));
        System.out.printf("Standard + 10,000 surcharge: %,d%n", STANDARD.plusSurcharge(10_000).fee(small));

        // 5) An enum that holds strategies
        for (Carrier c : Carrier.values()) {
            System.out.printf("%-15s fee ORD-2 = %,d%n", c, c.strategy.fee(big));
        }

        // 6) Use the JDK's built-in interfaces instead of declaring your own
        ToLongFunction<Order> weightFee = o -> (long) (o.weightKg() * 3_000);
        Function<Order, String> label = o -> o.id() + "/" + o.region();
        System.out.println(label.apply(big) + " weight-based fee = " + weightFee.applyAsLong(big));
    }

    static long flatFee(Order o) { return 25_000; }

    /** An enum that holds strategies: a closed set with stable names you can store in the DB. */
    enum Carrier {
        GHN(STANDARD), GHTK(FREE_OVER_500K), VIETTEL_EXPRESS(EXPRESS);
        final ShippingStrategy strategy;
        Carrier(ShippingStrategy s) { this.strategy = s; }
    }
}

Actual output (java -cp out StrategyLambda):

standard  ORD-1=330,000  ORD-2=840,000
express   ORD-1=369,000  ORD-2=893,000
free500k  ORD-1=330,000  ORD-2=800,000
Inline lambda (free inside Hanoi): 300,000
Method reference (flat fee): 825,000
Express capped at 80,000: fee ORD-1=69,000, ORD-2=80,000 (uncapped: 93,000)
Standard + 10,000 surcharge: 40,000
GHN             fee ORD-2 = 40,000
GHTK            fee ORD-2 = 0
VIETTEL_EXPRESS fee ORD-2 = 93,000
ORD-2/HCM weight-based fee = 10500

The first three lines are identical to the output of the class-based version, which shows the two ways of writing the Strategy pattern behave the same. The later lines show that lambdas give you more than a shorter syntax:

  • Registry instead of switch: a Map<String, ShippingStrategy> maps a configuration key to a strategy. Choosing a formula becomes a single table lookup, and adding a formula does not touch the selection code.
  • Inline strategy: a one-off rule such as "free inside Hanoi" needs neither a name nor a class.
  • Method reference: an existing static method (StrategyLambda::flatFee) becomes a strategy as long as the signature matches.
  • Composing strategies with default methods: withCap(80_000) returns a new strategy that wraps the old one. The Express fee for ORD-2 drops from 93,000 to 80,000. This is the same idea as Comparator.thenComparing in the JDK.
  • Enum that holds strategies: Carrier ties each shipping carrier to a strategy. The set of strategies is closed and the enum names are stable enough to store in a database, which a lambda on its own cannot offer.

The Strategy pattern in the JDK: Comparator

The most familiar example of the Strategy pattern in the JDK is java.util.Comparator. The List.sort(Comparator) method is the context: the sorting algorithm is fixed, while "what counts as smaller" is the strategy you pass in. According to the Java SE 17 Comparator documentation, it is a functional interface with a compare method, plus static and default methods such as comparing, thenComparing and reversed that combine small strategies into larger ones:

public class JdkStrategy {
    record Product(String name, long price, int sold) { }

    public static void main(String[] args) {
        List<Product> ps = new ArrayList<>(List.of(
                new Product("Keyboard", 900_000, 120),
                new Product("Mouse", 250_000, 300),
                new Product("Headset", 600_000, 120),
                new Product("Mouse pad", 100_000, 500)));

        ps.sort(Comparator.comparingLong(Product::price));
        System.out.println("Price ascending   : " + names(ps));

        ps.sort(Comparator.comparingInt(Product::sold).reversed().thenComparing(Product::name));
        System.out.println("Best-selling, name: " + names(ps));

        ps.sort(Comparator.comparing(Product::name, String.CASE_INSENSITIVE_ORDER));
        System.out.println("By name           : " + names(ps));
    }
    static List<String> names(List<Product> ps) { return ps.stream().map(Product::name).toList(); }

Run java -cp out JdkStrategy:

Price ascending   : [Mouse pad, Mouse, Headset, Keyboard]
Best-selling, name: [Mouse pad, Mouse, Headset, Keyboard]
By name           : [Headset, Keyboard, Mouse, Mouse pad]

The second line shows the power of composing a Strategy pattern: sort by units sold in descending order, and when two products both sold 120, keep sorting by name, so "Headset" comes before "Keyboard". The same Strategy pattern idea appears in Collections.sort, TreeMap and Stream.sorted, and with Predicate in Stream.filter or Function in Stream.map. The java.util.function package provides general-purpose functional interfaces such as Function, Predicate, UnaryOperator and BiFunction, so very often a Strategy pattern does not need a custom interface.

When to use a lambda and when to use a class for the Strategy pattern

A lambda is not always better than a class in the Strategy pattern. The table below is a practical way to choose when implementing the Strategy pattern:

SituationUse
One-line algorithm used in one placeInline lambda
Reused in many places, needs a name and business documentationLambda constant assigned to a named interface (like STANDARD)
Strategy has state, configuration or dependencies (connections, cache)A dedicated class, usually a bean managed by a container
Several related methods must be implemented togetherA class, because a lambda represents only one method
Closed set of strategies, name stored in the DBEnum that holds strategies
You need to log or monitor the strategy nameA named class or an enum, because a lambda has no business name

Refactoring.guru also points out that if you only have a few stable algorithms, adding classes and an interface is needless complexity, and the client has to understand the differences between strategies to pick the right one. With Spring, the Strategy pattern is often used by registering beans that implement the same interface and injecting them as a Map<String, ShippingStrategy> keyed by bean name, which gives the same registry effect without any registration code.

Telling the Strategy pattern apart from State, Bridge and Command

  • Strategy pattern vs State: both use composition with the same structure. The main difference is that strategies are independent and unaware of each other, while states may know about each other and switch to another state themselves.
  • Strategy pattern vs Bridge: as the previous article explained, Bridge separates two dimensions of a design, while Strategy swaps algorithms that are interchangeable.
  • Strategy pattern vs Command: Command turns an operation into an object so it can be queued, logged in a history, or executed later; Strategy describes different ways of doing the same job inside one context.

Common mistakes when using lambdas as strategies

Some traps of the lambda-style Strategy pattern only show up at runtime. The code below collects four traps, one short snippet each:

    public static void main(String[] args) {
        // 1) A lambda's toString carries no business meaning -> hard to log/debug
        LongUnaryOperator vat = x -> x * 110 / 100;
        System.out.println("toString lambda: " + vat.toString().replaceAll("/0x[0-9a-f]+@[0-9a-f]+", "/0x...@..."));
        System.out.println("Lambda class is a hidden class: " + vat.getClass().isHidden());

        // 2) Captured variables in a lambda must be effectively final; changing state must live in an object
        int rate = 10;
        LongUnaryOperator withRate = x -> x * (100 + rate) / 100;
        // rate++;   // uncommenting this line causes a compile error: local variables referenced from a lambda expression must be final or effectively final
        System.out.println("Price with 10% VAT: " + withRate.applyAsLong(1_000_000));

        // 3) A stateful strategy shared between threads: a lambda that captures a List can break
        List<Long> audit = new ArrayList<>();
        LongUnaryOperator audited = x -> { audit.add(x); return x; };
        audited.applyAsLong(5);
        audited.applyAsLong(7);
        System.out.println("audit (safe only with one thread): " + audit);

        // 4) Two identical lambdas are still two different objects (equals does not compare them)
        LongUnaryOperator a = x -> x + 1;
        LongUnaryOperator b = x -> x + 1;
        System.out.println("a.equals(b) = " + a.equals(b));
    }

Output (java -cp out Gotchas). The lambda class name depends on the JDK version: the run here printed Gotchas$$Lambda/0x..., while Java 17 may print an extra suffix such as $$Lambda$14/0x...; the hash code and address have been abbreviated:

toString lambda: Gotchas$$Lambda/0x...@...
Lambda class is a hidden class: true
Price with 10% VAT: 1100000
audit (safe only with one thread): [5, 7]
a.equals(b) = false
  • No name to log: a lambda's toString() gives only a hidden class name with a hash code. If you need to log "which strategy was applied", use an enum, a named class, or wrap the strategy together with a label.
  • Captured variables must be effectively final: according to the Oracle Java Tutorials, a lambda can only access local variables that are final or effectively final. If you uncomment rate++, the compiler reports "local variables referenced from a lambda expression must be final or effectively final". State that needs to change must live in an object, not in a local variable.
  • State shared between threads: a lambda that captures an ArrayList and is called from several threads will lose data or throw. A shared strategy should be stateless, or use a thread-safe structure.
  • Not comparable: two identical lambdas still give false from equals. Do not use a strategy as a Map key or check "is it configured yet" by comparing objects.
  • A strategy that is too large for a lambda: a lambda of dozens of lines loses the compactness that is its advantage. When the body is long, extract it into a named method and use a method reference.
  • Choosing the strategy in the wrong place: in the Strategy pattern, if the context still has if (type == EXPRESS) to choose, you have only moved the branching chain somewhere else. The choice belongs in a registry or in configuration.

Checklist for applying the Strategy pattern

  1. Is the Strategy pattern worth it: are there two or more variants of the same algorithm, and do you expect to add more?
  2. Does the strategy interface have only one main method? If so, mark it @FunctionalInterface.
  3. If the strategy is stateless, use a lambda constant; if it has state or dependencies, use a class.
  4. Is the choice of strategy in a single place (registry, enum, configuration) rather than scattered through the code?
  5. Have you written tests for each strategy on its own, and tested the context with a fake strategy?
  6. Does the strategy name appear in logs or get stored in the DB? If so, use an enum or a named class.

All code in this article was compiled with --release 17 and re-run before the output was pasted, on a machine with 16 GB of RAM.

Next article

The next article in the series is Observer pattern: from listeners to event buses and the Flow API. If the Strategy pattern lets you change behavior, Observer lets many objects react when an event happens, and we will see how the JDK supports that.