Software Engineer's Blog

Open-Closed Principle in Java: Open to Extension, Closed to Modification

Open-Closed Principle in Java: Open to Extension, Closed to Modification

The Open-Closed Principle, also known as OCP, is the second principle in SOLID.

The definition is:

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

At first, this sentence can sound a little strange.

How can something be open and closed at the same time?

A simpler way to understand it is this:

We should be able to add new behavior without constantly changing existing code.

This does not mean we never modify code. That would be unrealistic.
In real projects, we modify code all the time.

The point is that when the system grows, we should avoid changing stable business logic every time we add a new type or feature.

Let’s look at a simple Java example.

A Bad Example: Payment Logic with If-Else

Imagine we are building a payment feature.

At first, the system only supports credit card payment.

public class PaymentService {

    public void pay(String paymentType, int amount) {
        if (paymentType.equals("CARD")) {
            System.out.println("Pay by credit card: " + amount);
        }
    }
}

The usage is simple:

public class Main {

    public static void main(String[] args) {
        PaymentService paymentService = new PaymentService();

        paymentService.pay("CARD", 10000);
    }
}

This code works.

But later, the business asks us to add PayPal.

So we modify the existing PaymentService.

public class PaymentService {

    public void pay(String paymentType, int amount) {
        if (paymentType.equals("CARD")) {
            System.out.println("Pay by credit card: " + amount);
        } else if (paymentType.equals("PAYPAL")) {
            System.out.println("Pay by PayPal: " + amount);
        }
    }
}

After that, the business asks us to add Naver Pay.

So we modify the same class again.

public class PaymentService {

    public void pay(String paymentType, int amount) {
        if (paymentType.equals("CARD")) {
            System.out.println("Pay by credit card: " + amount);
        } else if (paymentType.equals("PAYPAL")) {
            System.out.println("Pay by PayPal: " + amount);
        } else if (paymentType.equals("NAVER_PAY")) {
            System.out.println("Pay by Naver Pay: " + amount);
        }
    }
}

This is a common pattern in real projects.

At first, it feels simple.
But as the number of payment methods grows, the service becomes harder to maintain.

Every new payment method requires us to modify existing logic.

Add Card payment     → modify PaymentService
Add PayPal payment   → modify PaymentService
Add Naver Pay        → modify PaymentService
Add Apple Pay        → modify PaymentService
Add Kakao Pay        → modify PaymentService

This is the kind of design OCP tries to improve.

What Is the Problem?

The problem is not just the if-else statement itself.

The real problem is that PaymentService knows too much about every payment type.

It knows:

  • how to process card payment
  • how to process PayPal payment
  • how to process Naver Pay
  • how to choose between them

As more payment methods are added, this class keeps growing.

That creates several problems:

  • the class becomes harder to read
  • the risk of breaking existing payment logic increases
  • testing becomes more complicated
  • every new payment method requires changes to old code

In other words, the class is not closed for modification.

Applying OCP

A better design is to introduce an abstraction.

In this case, we can create a PaymentMethod interface.

public interface PaymentMethod {

    void pay(int amount);
}

Now each payment method can implement this interface.

public class CardPayment implements PaymentMethod {

    @Override
    public void pay(int amount) {
        System.out.println("Pay by credit card: " + amount);
    }
}
public class PaypalPayment implements PaymentMethod {

    @Override
    public void pay(int amount) {
        System.out.println("Pay by PayPal: " + amount);
    }
}
public class NaverPayPayment implements PaymentMethod {

    @Override
    public void pay(int amount) {
        System.out.println("Pay by Naver Pay: " + amount);
    }
}

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

public class PaymentService {

    public void pay(PaymentMethod paymentMethod, int amount) {
        paymentMethod.pay(amount);
    }
}

And we can use it like this:

public class Main {

    public static void main(String[] args) {
        PaymentService paymentService = new PaymentService();

        PaymentMethod paymentMethod = new CardPayment();
        paymentService.pay(paymentMethod, 10000);
    }
}

If we want to use PayPal:

PaymentMethod paymentMethod = new PaypalPayment();
paymentService.pay(paymentMethod, 10000);

If we want to use Naver Pay:

PaymentMethod paymentMethod = new NaverPayPayment();
paymentService.pay(paymentMethod, 10000);

The important point is this:

We can add a new payment method by creating a new class.
We do not need to modify the core PaymentService.

That is the basic idea of OCP.

Adding a New Payment Method

Let’s say we now need to add Apple Pay.

With the old design, we would have to modify the if-else block inside PaymentService.

With the OCP-friendly design, we just add a new class.

public class ApplePayPayment implements PaymentMethod {

    @Override
    public void pay(int amount) {
        System.out.println("Pay by Apple Pay: " + amount);
    }
}

Then we use it:

PaymentMethod paymentMethod = new ApplePayPayment();
paymentService.pay(paymentMethod, 10000);

The existing PaymentService remains unchanged.

This is what “open for extension, closed for modification” means.

Open for extension      → we can add ApplePayPayment
Closed for modification → we do not modify PaymentService

A Spring Boot Style Example

In Spring Boot, this pattern appears very often.

We can define a payment interface:

public interface PaymentMethod {

    String getType();

    void pay(int amount);
}

Then each implementation can provide its own type.

@Component
public class CardPayment implements PaymentMethod {

    @Override
    public String getType() {
        return "CARD";
    }

    @Override
    public void pay(int amount) {
        System.out.println("Pay by credit card: " + amount);
    }
}
@Component
public class PaypalPayment implements PaymentMethod {

    @Override
    public String getType() {
        return "PAYPAL";
    }

    @Override
    public void pay(int amount) {
        System.out.println("Pay by PayPal: " + amount);
    }
}
@Component
public class NaverPayPayment implements PaymentMethod {

    @Override
    public String getType() {
        return "NAVER_PAY";
    }

    @Override
    public void pay(int amount) {
        System.out.println("Pay by Naver Pay: " + amount);
    }
}

Now we can create a resolver that finds the correct payment method.

@Component
public class PaymentMethodResolver {

    private final Map<String, PaymentMethod> paymentMethods;

    public PaymentMethodResolver(List<PaymentMethod> paymentMethodList) {
        this.paymentMethods = paymentMethodList.stream()
                .collect(Collectors.toMap(
                        PaymentMethod::getType,
                        paymentMethod -> paymentMethod
                ));
    }

    public PaymentMethod resolve(String type) {
        PaymentMethod paymentMethod = paymentMethods.get(type);

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

        return paymentMethod;
    }
}

Then PaymentService can use the resolver.

@Service
public class PaymentService {

    private final PaymentMethodResolver paymentMethodResolver;

    public PaymentService(PaymentMethodResolver paymentMethodResolver) {
        this.paymentMethodResolver = paymentMethodResolver;
    }

    public void pay(String paymentType, int amount) {
        PaymentMethod paymentMethod = paymentMethodResolver.resolve(paymentType);
        paymentMethod.pay(amount);
    }
}

Now if we add a new payment method, we only need to create a new implementation class.

For example:

@Component
public class ApplePayPayment implements PaymentMethod {

    @Override
    public String getType() {
        return "APPLE_PAY";
    }

    @Override
    public void pay(int amount) {
        System.out.println("Pay by Apple Pay: " + amount);
    }
}

Spring will automatically include it in the List<PaymentMethod>.

So we can extend the system without modifying the existing PaymentService.

Is OCP Always Necessary?

Not always.

This is important.

If there are only two simple cases and the logic is not expected to grow, a simple if-else may be perfectly fine.

For example:

if (user.isActive()) {
    // active user logic
} else {
    // inactive user logic
}

We do not need to create an interface for every small condition.

OCP becomes more useful when:

  • new types are expected to be added
  • the logic for each type is different
  • the existing class keeps growing
  • testing becomes harder
  • changes in one type can accidentally affect another type

In other words, OCP is useful when variation is expected.

For payment methods, notification channels, discount policies, report exporters, file parsers, and external API integrations, OCP often gives us a cleaner design.

OCP and Too Many If-Else Statements

A common sign of an OCP problem is a large if-else or switch statement based on type.

For example:

switch (paymentType) {
    case "CARD":
        // card payment
        break;
    case "PAYPAL":
        // PayPal payment
        break;
    case "NAVER_PAY":
        // Naver Pay payment
        break;
    case "APPLE_PAY":
        // Apple Pay payment
        break;
}

This is not always bad.

But if this structure keeps growing whenever a new business type is added, it may be a sign that we need abstraction.

Instead of asking:

How can I add one more condition?

We can ask:

Is this behavior something that should be represented as a separate class?

That question often leads to a better design.

Before and After

Before OCP:

public class PaymentService {

    public void pay(String paymentType, int amount) {
        if (paymentType.equals("CARD")) {
            System.out.println("Pay by credit card: " + amount);
        } else if (paymentType.equals("PAYPAL")) {
            System.out.println("Pay by PayPal: " + amount);
        } else if (paymentType.equals("NAVER_PAY")) {
            System.out.println("Pay by Naver Pay: " + amount);
        }
    }
}

Problem:

Every new payment method requires modifying PaymentService.

After OCP:

public interface PaymentMethod {

    void pay(int amount);
}
public class CardPayment implements PaymentMethod {

    @Override
    public void pay(int amount) {
        System.out.println("Pay by credit card: " + amount);
    }
}
public class NaverPayPayment implements PaymentMethod {

    @Override
    public void pay(int amount) {
        System.out.println("Pay by Naver Pay: " + amount);
    }
}
public class PaymentService {

    public void pay(PaymentMethod paymentMethod, int amount) {
        paymentMethod.pay(amount);
    }
}

Better design:

New behavior can be added by creating a new class.
Existing business logic does not need to change.

OCP and SRP

OCP is closely related to SRP.

SRP helps us separate responsibilities.

OCP helps us extend behavior without changing stable code.

For example, if one PaymentService contains all payment logic, it probably violates SRP because it has too many responsibilities.

Once we separate each payment behavior into its own class, the design also becomes more OCP-friendly.

So these principles often work together.

A good design is not about applying one principle in isolation.
It is about making the code easier to change safely.

A Practical Way to Think About OCP

When I think about OCP, I usually ask these questions:

Will this type of logic grow in the future?
Will we add more cases later?
Am I modifying the same class every time a new type is added?
Can this behavior be moved behind an interface?

If the answer is yes, OCP may be helpful.

For example:

New payment method      → add a new PaymentMethod class
New notification channel → add a new NotificationSender class
New discount rule       → add a new DiscountPolicy class
New report format       → add a new ReportExporter class

This is the practical value of OCP.

It helps us design code that can grow without constantly rewriting existing logic.

Summary

The Open-Closed Principle means that code should be open for extension but closed for modification.

In practice, this means we should try to add new behavior by adding new classes, rather than modifying stable existing logic every time.

In the payment example, a large if-else block inside PaymentService makes the code harder to maintain as payment methods increase.

By introducing a PaymentMethod interface and creating separate classes like CardPayment, PaypalPayment, and NaverPayPayment, we can add new payment methods more safely.

OCP is especially useful when a system has behavior that is expected to grow over time.

The key question is:

Can I add this new behavior without changing existing business logic too much?

If the answer is yes, the design is likely moving in the right direction.