Java: Applying the Strategy Pattern Without Overengineering

As business logic grows, applications often accumulate conditional statements that select different behaviors based on payment methods, shipping options, pricing rules, or notification channels.

The Strategy pattern provides a way to represent interchangeable algorithms behind a common interface.

Recognize the Conditional Logic Problem

Consider a service that calculates shipping costs:

public BigDecimal calculateShipping(
        String method,
        BigDecimal weight) {

    if ("STANDARD".equals(method)) {
        return weight.multiply(new BigDecimal("2.00"));
    }

    if ("EXPRESS".equals(method)) {
        return weight.multiply(new BigDecimal("5.00"));
    }

    throw new IllegalArgumentException(
        "Unsupported shipping method"
    );
}

This is perfectly understandable with two options. As the number of methods grows, however, the service can become difficult to extend and test.

Introduce a Strategy Interface

Represent the calculation as an interface:

import java.math.BigDecimal;

public interface ShippingStrategy {
    BigDecimal calculateCost(BigDecimal weight);
}

Then implement each behavior separately:

public class StandardShipping
        implements ShippingStrategy {

    @Override
    public BigDecimal calculateCost(BigDecimal weight) {
        return weight.multiply(new BigDecimal("2.00"));
    }
}
public class ExpressShipping
        implements ShippingStrategy {

    @Override
    public BigDecimal calculateCost(BigDecimal weight) {
        return weight.multiply(new BigDecimal("5.00"));
    }
}

Each class now has one clear responsibility.

The example uses fixed rates for simplicity. Real shipping calculations may also depend on package dimensions, destination, minimum charges, and currency rules.

Inject the Selected Strategy

A service can depend on the interface rather than on individual implementations:

public class ShippingService {
    private final ShippingStrategy strategy;

    public ShippingService(ShippingStrategy strategy) {
        this.strategy = strategy;
    }

    public BigDecimal calculate(BigDecimal weight) {
        return strategy.calculateCost(weight);
    }
}

The application chooses the implementation when constructing the service:

ShippingStrategy strategy = new ExpressShipping();
ShippingService service = new ShippingService(strategy);

This makes it straightforward to test the service with a fake implementation or introduce another strategy without changing its calculation logic.

Selecting Strategies Dynamically

If a request can choose among multiple shipping methods, a registry can map identifiers to implementations.

Map<String, ShippingStrategy> strategies = Map.of(
    "STANDARD", new StandardShipping(),
    "EXPRESS", new ExpressShipping()
);

Validate the requested identifier and handle unsupported values explicitly. In a dependency-injection framework, strategies can also be registered and resolved through the container.

Avoid turning this registry into a complicated plugin system unless the application genuinely needs dynamic extension.

When the Strategy Pattern Is Not Needed

A pattern should solve a real design problem, not merely introduce additional classes.

If a method contains two short, stable branches, a simple conditional may be easier to read. The Strategy pattern becomes more valuable when behaviors are independently testable, likely to expand, or selected in multiple parts of the application.

Do not create an interface for every small method automatically.

Test Each Strategy Independently

Each implementation should have tests for normal inputs and boundary conditions. The service itself can be tested with a simple fake strategy to verify that it delegates correctly.

This separation helps identify whether a failure belongs to the selected algorithm or to the code that chooses and invokes it.

Conclusion

The Strategy pattern separates interchangeable behaviors behind a common interface. It can reduce growing conditional logic and make implementations easier to test, but it should be introduced when the variation justifies the additional structure.

Good design keeps change localized without making simple code unnecessarily complicated.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *