beginner~1.5h

Open/Closed Principle

Why growing an if/else chain every time a new case appears is a design smell, and how to make a class open to new behavior without ever reopening its already-tested code.

Learning objectives

  • State the Open/Closed Principle and explain what 'open' and 'closed' each refer to
  • Recognize an if/else or switch chain over a type as a common OCP violation
  • Refactor a type-switch into a polymorphic design that accepts new cases as new classes
  • Judge when applying OCP proactively is worth the added abstraction, and when it's premature

◆ Story

Adding a new appliance to your house shouldn't require rewiring the wall behind it — you plug the appliance into an existing power strip, and the wall's own wiring never has to change. The power strip is genuinely open to new appliances plugging in, while staying closed — untouched, unrisked — itself.

The Open/Closed Principle (OCP) asks the same thing of a class: it should be open for extension (you can add new behavior) but closed for modification (you don't have to edit its existing, already-tested code to do so).

This topic builds a PaymentProcessor that accepts a payment method and an amount and processes the payment differently depending on what that method is — credit card, debit card, PayPal, and eventually more. The question worth carrying through the whole example: when a new payment method shows up, what exactly has to change?

The most direct instinct: one method, one parameter describing the payment type, one branch per type. This runs fine for three payment types, and reads clearly enough at this size.

Now the real requirement lands: support UPI too, since it's become one of the most commonly used payment methods in a lot of markets. Adding it means opening processPayment() — a method that's already shipped, already tested, and already trusted with credit card, debit card, and PayPal traffic — and adding a fourth branch directly inside it.

💻 Code example

class PaymentProcessor { void processPayment(String paymentMethod, double amount) { if (paymentMethod.equals("credit_card")) { System.out.println("Paying via credit card: " + amount); } else if (paymentMethod.equals("debit_card")) { System.out.println("Paying via debit card: " + amount); } else if (paymentMethod.equals("paypal")) { System.out.println("Paying via PayPal: " + amount); } else { throw new IllegalArgumentException("Unsupported payment method: " + paymentMethod); } } }
  • Every edit to working code is a risk to working code. A typo in the new UPI branch sits in the same method body as the credit-card branch that a thousand transactions already depend on — there's no wall between new, unproven code and old, trusted code.
  • This method has to know about every payment type that will ever exist, forever. That's backwards — a payment method should know how to process itself; centralizing that knowledge in one ever-growing method is the opposite of encapsulation.
  • The if/else chain only grows. Five more payment methods means five more branches in the same method, and nothing about this design puts a ceiling on that. Every new branch also makes the method harder to read and easier to introduce a bug into, since a developer has to hold the entire chain in their head to safely add one more else if.

This is exactly what OCP means by "closed for modification": processPayment() should never need to be reopened just because the roster of payment types grew. Right now, it does — every single new payment method requires touching a method that's already correct for every case it currently handles.

Reason it through the way you would at a whiteboard: PaymentProcessor shouldn't need to know the specific list of payment types — it only needs to know that whatever it's given can process a payment somehow. Each payment type already knows how to process itself; that logic is currently just misplaced, sitting inside PaymentProcessor instead of on the type it actually belongs to. So the fix is to give every payment type a shared contract — one method, pay() — and let PaymentProcessor call that, generically, without caring which concrete type it's holding.

Each existing branch becomes its own class implementing a shared PaymentMethod interface, and PaymentProcessor shrinks from four branches of business logic down to a single delegating line. It no longer has any idea how many payment types exist — that knowledge now lives, correctly, inside each concrete class.

Proving it: adding UPI support now means writing exactly one new class. PaymentProcessor.processPayment() is not opened, not edited, not even recompiled with new logic inside it — the entire diff required to support a new payment type is one new file.

💻 Code example

interface PaymentMethod { void pay(double amount); // every payment type must be able to do exactly this } class CreditCard implements PaymentMethod { public void pay(double amount) { System.out.println("Paying via credit card: " + amount); } } class DebitCard implements PaymentMethod { public void pay(double amount) { System.out.println("Paying via debit card: " + amount); } } class PayPal implements PaymentMethod { public void pay(double amount) { System.out.println("Paying via PayPal: " + amount); } } class PaymentProcessor { void processPayment(PaymentMethod paymentMethod, double amount) { paymentMethod.pay(amount); // CLOSED — this line never changes again, no matter how many types get added } } // adding UPI later — the entire diff, nothing else in the codebase is touched class UPI implements PaymentMethod { public void pay(double amount) { System.out.println("Paying via UPI: " + amount); } } PaymentProcessor processor = new PaymentProcessor(); processor.processPayment(new UPI(), 120.0);

▲ Edge case — don't over-apply this from day one

Designing every class to be maximally "open for extension" from the very first line of code is a genuine, common over-engineering trap — you end up with interfaces and abstraction layers for extension points that may never actually be needed. OCP earns its value specifically at points in your code that demonstrably change often — like this topic's payment types, where new payment methods are a known, recurring requirement — not universally, everywhere, preemptively. A one-off utility method that will realistically never grow a second case doesn't need an interface built around it just in case.

▲ Edge case — OCP and testing don't automatically improve together

Swapping an if/else chain for polymorphism makes each case independently testable, which is a real win — but only if each implementing class is kept small and focused. A PaymentMethod implementation that grows its own internal if/else chain over sub-cases has just moved the same violation one level down, rather than actually fixing it.

Worth remembering: this fix is structurally identical to the polymorphism shown when building PaymentService in the OOP Recap topic — OCP isn't a new mechanism, it's a specific, deliberate reason to reach for polymorphism, so that adding a new case never requires touching code that already works. The Factory pattern, covered later in this course, generalizes this same idea specifically for the moment of creating the right object in the first place.

Payment gateways like Stripe and Razorpay are built almost entirely around this shape — a generic charge() call that dispatches to whichever payment method implementation was configured, with new payment rails added as new implementations rather than new branches in a central switch statement. Plugin systems in IDEs and browsers follow the identical idea: the host application is closed for modification, and every plugin is a new implementation of a shared extension contract, installed without ever touching the host's own source.

Java's Comparator interface is a clean standard-library example: sorting logic is supplied as a new implementation of compareTo or a lambda satisfying Comparator, and Collections.sort() itself never needs to change no matter how many different sort orders get invented. Servlet filters and Spring interceptors work the same way — new cross-cutting behavior gets added as a new filter class, plugged into an existing chain, without editing the servlet container or the filter-dispatch mechanism itself.

Q: What does "open for extension, closed for modification" actually mean?

A: You should be able to add new behavior (extension) without editing existing, already-tested code (modification) — typically by adding a new class that implements a shared interface, rather than adding a new branch to existing logic.

Q: Why is an if/else chain over a type string specifically an OCP violation, and not just ugly code?

A: Because every new case requires reopening and re-testing a method that already works, and the method has to know about every case that will ever exist — the two things OCP explicitly says a class shouldn't have to do.

Q: How would you prove, concretely, that a design follows OCP?

A: Add a new case (like this topic's UPI) and check whether any existing, already-shipped file needed to change. If the only diff is one new class, OCP holds.

Q: Is designing everything to be maximally extensible from day one a good idea?

A: No — that's over-engineering. OCP earns its value at points that demonstrably change often, not universally applied everywhere in advance.

Q: How does OCP relate to runtime polymorphism?

A: Polymorphism is the mechanism; OCP is the reason you reach for it — replacing a type-check with a virtual method call is what lets new types plug in without modifying the code that calls them.

Want a visual for this concept?

Generate a diagram tailored to “Open/Closed Principle” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to Liskov Substitution Principle← Back to all Low-Level Design & Design Patterns chapters