Software Engineer's Blog

Applying OCP with the Strategy Pattern in Java

Applying OCP with the Strategy Pattern in Java

When an application grows, one of the first signs of weak design is a large if-else or switch block that keeps getting bigger every time a new requirement is added.

At first, this kind of code feels simple and practical. But over time, it becomes harder to change, harder to test, and easier to break.

This is where the Open-Closed Principle, or OCP, becomes useful.

OCP is one of the SOLID principles, and it is often applied through patterns like Strategy, Factory, and polymorphism.

In this post, I’ll focus on how to apply OCP using the Strategy Pattern in Java.


What is OCP?

OCP stands for Open-Closed Principle.

The idea is simple:

Software should be open for extension, but closed for modification.

In plain English:

We should be able to add new behavior without repeatedly changing existing core logic.

This does not mean we can never modify existing code. That would be unrealistic.

In real projects, we still modify code for bug fixes, refactoring, and changing business requirements.

The real point of OCP is this:

If the same class needs to be modified every time a new type or behavior is added, the design may be too tightly coupled.

Let’s look at a common example: payment processing.


A Simple Payment Example

Imagine we have a service that processes payments.

At the beginning, the system supports only credit card payments.

Later, PayPal is added.

Then crypto payment is added.

Then maybe Apple Pay, Google Pay, or bank transfer.

A simple implementation might look like this:

class PaymentService {
    void pay(String type, Order order) {
        if (type.equals("CREDIT_CARD")) {
            // Credit card payment logic
        } else if (type.equals("PAYPAL")) {
            // PayPal payment logic
        } else if (type.equals("CRYPTO")) {
            // Crypto payment logic
        }
    }
}

This code is easy to understand when there are only two or three payment types.

But the problem becomes clear when the business keeps adding new payment methods.

Every new payment type requires changing PaymentService.

class PaymentService {
    void pay(String type, Order order) {
        if (type.equals("CREDIT_CARD")) {
            // Credit card payment logic
        } else if (type.equals("PAYPAL")) {
            // PayPal payment logic
        } else if (type.equals("CRYPTO")) {
            // Crypto payment logic
        } else if (type.equals("APPLE_PAY")) {
            // Apple Pay payment logic
        }
    }
}

Now the service is doing too much.

It knows about every payment type.
It contains all payment-specific logic.
It must be changed every time a new payment method is added.

This is a typical OCP violation.


Why This Design Becomes a Problem

The main issue is not just that the code looks long.

The bigger problem is that PaymentService becomes unstable.

Every time we add a new payment method, we touch the same class again.

That increases the chance of breaking existing payment logic.

For example, while adding Apple Pay, we may accidentally affect PayPal or credit card handling.

Also, testing becomes more difficult because one class contains many different branches.

if (type.equals("CREDIT_CARD")) {
    // ...
} else if (type.equals("PAYPAL")) {
    // ...
} else if (type.equals("CRYPTO")) {
    // ...
}

As the number of branches grows, the number of test cases also grows.

At some point, the service becomes a collection of unrelated payment rules.

That is usually a sign that we need to separate the behavior.


Applying the Strategy Pattern

The Strategy Pattern is a good way to solve this problem.

The idea is to define a common interface and move each behavior into its own class.

For payment processing, we can start with an interface:

public interface Payment {
    void process(Order order);
}

Each payment method becomes a separate implementation.

public class CreditCardPayment implements Payment {
    @Override
    public void process(Order order) {
        // Credit card payment logic
    }
}
public class PaypalPayment implements Payment {
    @Override
    public void process(Order order) {
        // PayPal payment logic
    }
}
public class CryptoPayment implements Payment {
    @Override
    public void process(Order order) {
        // Crypto payment logic
    }
}

Now PaymentService does not need to know the details of each payment method.

public class PaymentService {
    public void pay(Payment payment, Order order) {
        payment.process(order);
    }
}

This is much cleaner.

PaymentService only depends on the Payment interface.

The actual payment behavior is handled by each implementation.


Adding a New Payment Method

Now suppose we need to add bank transfer payment.

With the old if-else design, we would modify PaymentService.

With the Strategy Pattern, we simply add a new class.

public class BankTransferPayment implements Payment {
    @Override
    public void process(Order order) {
        // Bank transfer payment logic
    }
}

The existing PaymentService does not need to change.

public class PaymentService {
    public void pay(Payment payment, Order order) {
        payment.process(order);
    }
}

This is the main benefit of OCP.

We extend the system by adding new code, not by repeatedly modifying existing core logic.


Using the Strategy Pattern with Spring

In a Spring application, we can make this even more practical.

Each payment implementation can be registered as a Spring bean.

@Component("CREDIT_CARD")
public class CreditCardPayment implements Payment {
    @Override
    public void process(Order order) {
        // Credit card payment logic
    }
}
@Component("PAYPAL")
public class PaypalPayment implements Payment {
    @Override
    public void process(Order order) {
        // PayPal payment logic
    }
}
@Component("CRYPTO")
public class CryptoPayment implements Payment {
    @Override
    public void process(Order order) {
        // Crypto payment logic
    }
}

Spring can inject all implementations into a Map.

@Service
public class PaymentService {

    private final Map<String, Payment> payments;

    public PaymentService(Map<String, Payment> payments) {
        this.payments = payments;
    }

    public void pay(String type, Order order) {
        Payment payment = payments.get(type);

        if (payment == null) {
            throw new IllegalArgumentException("Unsupported payment type: " + type);
        }

        payment.process(order);
    }
}

In this example, the map key is the bean name.

So this bean:

@Component("CREDIT_CARD")
public class CreditCardPayment implements Payment {
    // ...
}

can be found with:

payments.get("CREDIT_CARD")

Now adding a new payment method is straightforward.

@Component("APPLE_PAY")
public class ApplePayPayment implements Payment {
    @Override
    public void process(Order order) {
        // Apple Pay payment logic
    }
}

The service does not need a new else if.

Spring wires the new implementation automatically.


Benefits of This Approach

The first benefit is that each payment method has its own place.

Credit card logic is inside CreditCardPayment.

PayPal logic is inside PaypalPayment.

Crypto logic is inside CryptoPayment.

This makes the code easier to read and maintain.

The second benefit is safer change.

When we add a new payment method, we add a new class instead of editing a large existing method.

That reduces the risk of accidentally breaking existing logic.

The third benefit is testing.

Each strategy can be tested independently.

class CreditCardPaymentTest {

    @Test
    void processCreditCardPayment() {
        Payment payment = new CreditCardPayment();

        payment.process(order);

        // Verify result
    }
}

The service can also be tested with fake or mock implementations.

This makes the design more flexible.


OCP Does Not Mean “Never Modify Code”

One thing I had to learn over time is that OCP is not an absolute rule.

It does not mean we should never change existing code.

That would be impossible in real software development.

OCP is more about reducing unnecessary changes to stable core logic.

For example, when we add a new payment type, some parts of the system may still need updates:

- API request values
- frontend dropdown options
- configuration
- documentation
- tests

That is normal.

The goal is not “zero code change everywhere.”

The goal is to avoid repeatedly modifying the same business logic class whenever a new variation is added.


When Not to Use This Pattern

It is also possible to overuse OCP.

If there are only two simple cases and they are unlikely to change, a small if-else may be perfectly fine.

Not every condition needs an interface.

For example, this may be enough:

if (order.isPaid()) {
    return;
}

Creating a strategy for every small condition can make the code harder to understand.

OCP becomes useful when:

- the number of types keeps growing
- each type has different behavior
- the same class keeps changing for every new type
- the logic is important enough to test separately
- the branching code is becoming difficult to read

In other words, apply OCP where the system actually changes.

Do not add abstraction just because a design pattern exists.


Common Signs That OCP May Help

There are some code smells that often suggest OCP could help.

One common sign is a large if-else block based on type.

if (type.equals("A")) {
    // A logic
} else if (type.equals("B")) {
    // B logic
} else if (type.equals("C")) {
    // C logic
}

Another sign is a large switch statement.

switch (paymentType) {
    case CREDIT_CARD:
        // Credit card logic
        break;
    case PAYPAL:
        // PayPal logic
        break;
    case CRYPTO:
        // Crypto logic
        break;
}

Another common sign is instanceof.

if (payment instanceof CreditCardPayment) {
    // Credit card logic
} else if (payment instanceof PaypalPayment) {
    // PayPal logic
}

In many cases, this can be replaced with polymorphism.

Instead of checking the type from outside, let the object handle its own behavior.

payment.process(order);

This is often cleaner and more object-oriented.


A More General Example: Notification Sending

The same idea can be used outside payment systems.

For example, suppose we have different notification channels.

public interface NotificationSender {
    void send(Message message);
}

Each channel can have its own implementation.

public class EmailSender implements NotificationSender {
    @Override
    public void send(Message message) {
        // Send email
    }
}
public class SmsSender implements NotificationSender {
    @Override
    public void send(Message message) {
        // Send SMS
    }
}
public class SlackSender implements NotificationSender {
    @Override
    public void send(Message message) {
        // Send Slack message
    }
}

If we later add WhatsApp notification, we add a new implementation.

public class WhatsAppSender implements NotificationSender {
    @Override
    public void send(Message message) {
        // Send WhatsApp message
    }
}

The core notification service does not need to know every detail of every channel.

It can work with the NotificationSender interface.

That is the same OCP idea applied to another domain.


Final Thoughts

The Open-Closed Principle is not about making code complicated.

It is about protecting stable code from unnecessary changes.

When a part of the system is likely to grow in variations, such as payment methods, notification channels, discount rules, or export formats, the Strategy Pattern can help keep the design clean.

The basic idea is simple:

Put common behavior behind an interface.
Move each variation into its own class.
Add new behavior by adding new implementations.

This makes the code easier to extend, easier to test, and safer to maintain.

A simple way to remember OCP is:

Add new behavior with new code, not by repeatedly editing old core logic.