The code
package SOLID.OCP.GoodCode;
// The processor can work with any object that keeps this contract.
public interface PaymentMethod {
void pay(double amount);
}
public class CreditCard implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Making payment via Credit Card : " + amount);
}
}
public class UPI implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Making payment via UPI : " + amount);
}
}
// DebitCard.java and Paypal.java implement the same interface.
public class PaymentProcessor {
public void processPayment(PaymentMethod paymentMethod, double amount) {
// Polymorphism chooses the implementation at runtime.
paymentMethod.pay(amount);
}
}Main chooses an implementation and passes it as the interface type:
PaymentProcessor processor = new PaymentProcessor();
processor.processPayment(new CreditCard(), 100);
processor.processPayment(new UPI(), 200);Read the second call in three steps: new UPI() creates the strategy, the processor receives it as a PaymentMethod, and paymentMethod.pay(200) dispatches to UPI.pay. This is polymorphism: the same pay call runs different code for each object.
What got better
- UPI was added without changing
PaymentProcessor. The processor is closed for modification when a new payment method arrives, and the set of implementations is open for extension. - Each method is isolated. A card change cannot accidentally edit UPI logic.
- Types replace internal strings. Code passes a
PaymentMethod, and an object of the wrong type fails to compile. - Tests become smaller. Test
UPIdirectly, or give the processor a fake method that records the amount. - Polymorphism replaces the business-logic chain. The caller never asks which subtype it received.
"Closed" does not mean the file can never change. Bugs and new requirements outside this extension point still require ordinary edits.
Where did the if/else go?
The decision did not disappear. An HTTP request may still contain the string "UPI", and something must map that value to an object. Keep that mapping at the application edge:
Map<String, PaymentMethod> methods = Map.of(
"CARD", new CreditCard(),
"UPI", new UPI());
// Illustration only: the repo also has DebitCard and Paypal, and its Main never uses a map.
PaymentMethod method = methods.get(request.method());
if (method == null) {
throw new IllegalArgumentException("Unsupported payment method: " + request.method());
}
processor.processPayment(method, request.amount());The map has one small job: turn a name from outside into an implementation. Adding Net Banking means adding its class and registering it here. Payment processing itself remains unchanged. A factory or a dependency-injection container (a framework that builds objects and passes them in for you) can own the same decision in a larger application.
One thing to watch
OCP only works if every implementation keeps the same promise. A PaymentMethod whose pay() always throws "not supported" compiles, but it cannot safely replace the others. That is the concern of LSP.
There is also a readability cost: a beginner must move between an interface and several classes. Use this design where variants really change. For one stable payment method, one concrete class is simpler.
Compare with BadCode, and read OCP-Notes.md for discount and retry examples. This structure is the Strategy pattern.