Skip to content

OCP - The Open/Closed Principle

Add new behaviour by adding new code, not by editing code that already works. Usually one interface and one class per variant.

Builds on Interfaces and Method overriding and polymorphism

Stage 2core10 min read

What it says

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

The wording sounds strict, but the idea is modest. Some kinds of change keep coming, like a new payment method. When an expected kind of change arrives, add a new piece. Do not keep editing code that already works for the old cases.

  • Open for extension means there is a clear place to add behavior, such as a new payment method, discount rule, or export format.
  • Closed for modification means the stable code that uses those behaviors does not need a new if branch for each new case.

Bertrand Meyer introduced the principle in 1988 with inheritance in mind: extend a finished class through a subclass. In modern Java, interfaces and composition (building behavior from small objects that work together) are usually easier to maintain. The caller depends on an interface. An interface is an abstraction: it says what can be done without fixing how. Each new behavior is another class that implements it.

Suppose checkout already handles cards and UPI. When Net Banking arrives, OCP aims for this change:

  1. add NetBanking implements PaymentMethod;
  2. register it where the application creates payment methods;
  3. leave PaymentProcessor and the tested card classes alone.

Two limits matter:

  1. No design is closed against every possible change. Choose the one kind of change that really happens (the axis of change). New payment methods are likely. A complete change to the meaning of money is not. Designing for every imagined change creates abstractions nobody can understand.
  2. Some code still changes. One place must learn that Net Banking exists. OCP moves that decision to one small place at the edge of the app, such as a factory, a configuration map, or a wiring class. The business logic does not repeat it.

So "closed" never means "never edit this file again." Bug fixes and unrelated requirements are still normal modifications.

An everyday analogy

Your phone does not need to be opened up with a screwdriver every time you want a new app. The phone defines a contract (the app platform), and new apps plug into it. The phone is closed for modification and open for extension.

Compare that with an old feature phone where the only way to get a new game was to buy a new phone.

How to spot a violation

  • An if/else or switch on a type string or enum that keeps growing. if (method.equals("UPI")) ... else if (method.equals("CARD")) ...
  • The same switch appears in several places. One for the fee, one for the refund, one for the display name. Adding a new type means hunting for all of them, and you will miss one.
  • instanceof chains. if (shape instanceof Circle c) ... else if (shape instanceof Square s) ... spread across business code.
  • Every new feature ticket touches the same file. Run git log on it and you see "add Paytm", "add PhonePe", "add Amazon Pay"...
  • Boolean flag parameters that pick behaviour. export(report, true, false) where the flags mean "pdf" and "excel".
  • Old features break when you add a new one. You added "Net Banking" and somehow the card flow failed in QA.
  • An else that throws "unsupported type". It is a sign the list of types is meant to grow.

Example 1: payment methods in this repo

Before

BadCode/PaymentProcessor.java:

public class PaymentProcessor {

    public void processPayment(String paymentMethod, double amount) {
        if (paymentMethod.equals("CreditCard")) {
            System.out.println("Payment via credit card: " + amount);
        } else if (paymentMethod.equals("DebitCard")) {
            System.out.println("Payment via debit card: " + amount);
        } else if (paymentMethod.equals("Paypal")) {
            System.out.println("Payment via paypal: " + amount);
        } else {
            throw new IllegalArgumentException("Unsupported payment method" + paymentMethod);
        }
    }
}

This class has no UPI branch. To support UPI you would open it and add another branch. Then you must re-test every branch, because they all live in one method. A typo like "upi" instead of "UPI" compiles fine and only fails at runtime. (Also notice the missing space in the error message. This kind of small bug is typical of code everyone is scared to touch.)

Loading diagram…

After

GoodCode gives every method its own class behind one interface:

public interface PaymentMethod {
    void pay(double amount);
}

public class UPI implements PaymentMethod {
    @Override
    public void pay(double amount) {
        System.out.println("Making payment via UPI : " + amount);
    }
}

public class PaymentProcessor {
    public void processPayment(PaymentMethod paymentMethod, double amount) {
        paymentMethod.pay(amount);
    }
}
Loading diagram…

processPayment no longer knows any payment method. It calls pay on whatever object it receives, and Java runs the pay of that object's class.

What changed and why it matters:

ConcernBeforeAfter
Add Net Bankingedit PaymentProcessoradd NetBanking implements PaymentMethod
Risk to the card flowsame method, so real riskcard code not touched at all
Typo in method nameruntime exceptioncompile error (it's a type now)
Test UPI logicgo through the big if/elsetest UPI alone

UPI was added in GoodCode without touching PaymentProcessor. That is OCP working.

Where did the if/else go?

Someone still has to turn the string "UPI" from the request into a UPI object. Look at GoodCode/Main.java: it does new UPI() by hand. In a real app this becomes one lookup at the edge:

Map<String, PaymentMethod> methods = Map.of(
        "CARD", new CreditCard(),
        "UPI", new UPI(),
        "PAYPAL", new Paypal());

PaymentMethod method = methods.get(request.method());
if (method == null) {
    throw new IllegalArgumentException("Unsupported payment method: " + request.method());
}
processor.processPayment(method, request.amount());

So OCP does not delete the decision. It moves it into one place that only maps names to objects, and keeps the business logic free of it. With Spring you don't even write the map. Ask for a Map<String, PaymentMethod> in a constructor and Spring (a Java framework that creates and connects objects for you) fills it with every implementation it finds, keyed by bean name (for example uPI, not "UPI", unless you name the beans yourself). Adding a payment method is then just one new @Component class.

Example 2: discounts on a food delivery cart

Discounts change every week. This is the kind of change you want to stay open for.

Before

public class DiscountCalculator {

    public double discount(Cart cart, String couponCode) {
        double total = cart.total();
        switch (couponCode) {
            case "FLAT100":
                return total >= 499 ? 100 : 0;
            case "TRYNEW":
                return Math.min(total * 0.5, 100);
            case "HDFC10":
                return cart.paymentBank().equals("HDFC") ? Math.min(total * 0.1, 150) : 0;
            default:
                return 0;
        }
    }
}

Marketing wants "FREEDEL: free delivery on orders above ₹199" by Friday. You edit this class. Next week they want "IPL50: ₹50 off during match hours". You edit it again. Every coupon launch is a change to the same method, reviewed and deployed with all the other coupons inside it.

After

public interface DiscountRule {
    boolean appliesTo(Cart cart);
    double discount(Cart cart);
}

public class FlatOffAboveMinimum implements DiscountRule {
    private final double minimum;
    private final double off;

    public FlatOffAboveMinimum(double minimum, double off) {
        this.minimum = minimum;
        this.off = off;
    }

    public boolean appliesTo(Cart cart) { return cart.total() >= minimum; }
    public double discount(Cart cart)  { return off; }
}

public class BankOffer implements DiscountRule {
    private final String bank;
    private final double percent;
    private final double cap;

    // constructor ...

    public boolean appliesTo(Cart cart) { return bank.equals(cart.paymentBank()); }
    public double discount(Cart cart)  { return Math.min(cart.total() * percent, cap); }
}

public class DiscountCalculator {
    private final Map<String, DiscountRule> rules;

    public DiscountCalculator(Map<String, DiscountRule> rules) {
        this.rules = rules;
    }

    public double discount(Cart cart, String couponCode) {
        DiscountRule rule = rules.get(couponCode);
        return rule != null && rule.appliesTo(cart) ? rule.discount(cart) : 0;
    }
}

DiscountCalculator finds the rule for the coupon code. It uses the rule only if appliesTo says yes for this cart. An unknown code, or a rule that does not apply, gives a discount of 0.

Wiring:

var calculator = new DiscountCalculator(Map.of(
        "FLAT100", new FlatOffAboveMinimum(499, 100),
        "HDFC10",  new BankOffer("HDFC", 0.10, 150)));
Loading diagram…

Now notice a second win. FLAT100 and a future FLAT50ABOVE299 are the same class with different numbers. Many new coupons need no new code at all, just a new entry in the map. The ones that do need code are a new class, reviewed on its own.

Example 3: adding retries without touching the sender

OCP is not only about "new types in a switch". It is also about adding behaviour around existing code.

You have an SMS sender for OTPs:

public interface SmsSender {
    void send(String phone, String text);
}

public class Msg91SmsSender implements SmsSender {
    @Override
    public void send(String phone, String text) {
        // HTTP call to the provider
    }
}

Ops reports that about 1 in 200 OTPs fails with a timeout. The quick fix is to open Msg91SmsSender and add a retry loop and some logging inside it. Then next month you switch providers and copy the same loop into the new class.

Instead, wrap it:

public class RetryingSmsSender implements SmsSender {
    private final SmsSender inner;
    private final int maxAttempts;

    public RetryingSmsSender(SmsSender inner, int maxAttempts) {
        this.inner = inner;
        this.maxAttempts = maxAttempts;
    }

    @Override
    public void send(String phone, String text) {
        for (int attempt = 1; ; attempt++) {
            try {
                inner.send(phone, text);
                return;
            } catch (SmsTimeoutException e) {
                if (attempt >= maxAttempts) throw e;
            }
        }
    }
}
SmsSender sender = new RetryingSmsSender(new Msg91SmsSender(), 3);

The loop calls inner.send. If it works, the method returns. On a timeout it tries again, until maxAttempts is reached. Then it rethrows the error. SmsTimeoutException is assumed to be an unchecked exception (Java does not force a method to declare it), since SmsSender.send doesn't declare any. Real code would also wait a little between attempts (backoff).

Msg91SmsSender was not modified. The retry works for any provider. If you later want logging, add a LoggingSmsSender the same way. This is the Decorator pattern, and it is one of the cleanest ways to follow OCP.

One trade-off to know before you wrap everything in retries: a timeout does not mean the message wasn't sent. The provider may have delivered it and only the reply got lost. For an OTP, a duplicate SMS is a minor annoyance. For a payment call, a blind retry can charge someone twice, so there you need an idempotency key (a unique id the provider uses to ignore repeats) before you retry anything.

When not to apply it

OCP has a cost: more classes, one more jump when reading code. Pay that cost only where change is real.

  • Don't add an interface for the first variant. If there is only one payment method and no sign of a second, write the class.
  • Use the rule of three as a default. One case: just code it. Two small cases: an if is usually fine. A third case, or a second one with real logic of its own: pull out the interface. By then you know what actually varies, so the interface comes out the right shape.
  • A fixed, small set of cases is fine as a switch. An enum OrderStatus { PLACED, PREPARING, OUT_FOR_DELIVERY, DELIVERED, CANCELLED } that changes once a year can be switched on. A switch expression with no default adds a safety check. If someone adds a new constant and does not handle it, the compiler gives an error, so it points you at every switch you forgot to update. Java 21 does the same for sealed types (types that list all their allowed subtypes) through pattern matching in switch. In Java 17 that was still a preview feature.
  • Don't build a plugin system for code only your team writes. Loading classes by name at runtime (reflection) or reading class names from config is rarely worth it inside one service.
  • Bugs are not extensions. If the GST formula is wrong, fix it in place. OCP is about adding features, not about never editing code again.
Add the abstraction whenKeep it simple when
The list of variants keeps growingThere is one variant and no roadmap for more
The same switch is copied in several placesThe switch exists in exactly one place
Different teams own different variantsThe variants are tiny one liners
Each variant has real logic and its own testsThe set is fixed and closed (an enum)

How it connects to the rest

  • SRP comes first. You can't extend pricing on its own if pricing is mixed with saving to the database. See SRP-Notes.md.
  • LSP is what makes OCP safe. PaymentProcessor trusts that every PaymentMethod behaves like a payment method. A subclass that throws in pay() breaks that trust. See LSP-Notes.md.
  • DIP is how you get there. PaymentProcessor depends on the PaymentMethod abstraction, not on UPI. See DIP-Notes.md.

Patterns that exist mostly to give you OCP:

  • Strategy: the payment and discount examples above are strategies.
  • Decorator: add behaviour around an object, like the retry wrapper.
  • Template Method: fix the steps of an algorithm, let subclasses fill in the parts that vary.
  • Observer: add a new listener (analytics, loyalty points) without editing the publisher.
  • Chain of Responsibility: add a new handler to the chain without touching the others.
  • Factory Method: the one place where "which class for this input" lives.

Before you commit, ask

  • Is there a switch or if/else on a type that will grow?
  • Is the same switch repeated anywhere else?
  • To add the next variant, would I edit this class or add a new one?
  • Could adding the next variant break an existing one?
  • Am I adding an interface for a second variant that actually exists, or for one I am imagining?
  • Is this a bug fix? Then just fix it, OCP doesn't apply.

Interview questions

"What does open for extension, closed for modification mean in practice?"

When a new case of an expected kind comes in, like a new payment method, you add a new class that implements an existing interface. The class that runs all payments does not change. Existing, tested code stays untouched, so you don't risk breaking the old cases.

"How do you usually achieve OCP in Java?"

With polymorphism through an interface or abstract class, so the core code calls method.pay(amount) and never asks which type it is. Strategy, Decorator, Template Method and Observer are all common ways to do it. Composition is usually preferred over deep inheritance.

"If you replace an if/else with classes, where does the decision go?"

It moves to one place at the edge, usually a factory, a Map<String, PaymentMethod>, or the DI container injecting a list of implementations. The decision still exists, but only that small mapping changes when a new type arrives, not the business logic.

"Can you follow OCP for every possible change?"

No. You choose the axis of change you expect, like new payment methods or new coupons, and keep the code open on that axis. Trying to be open in every direction gives you a pile of abstractions nobody needs. Bug fixes are also normal edits. OCP is not about never touching code.

"Is a switch statement always an OCP violation?"

No. A switch over a small, fixed set like an order status enum is fine. A switch expression over an enum, with no default, even makes the compiler flag any case you forgot. It becomes a problem when the set keeps growing and the same switch is copied into many places.

"How does OCP relate to LSP?"

OCP lets you plug in new subtypes without editing the caller. That only works if every subtype honours the contract the caller relies on, which is exactly what LSP asks for. A new PaymentMethod that throws in pay() breaks both.

In this topic

Done with this topic?

How well do you know it?

PreviousSRP - The Single Responsibility Principle
Practise 6 questions