Open/Closed
TL;DR: Classes should be open for extension but closed for modification. When you need to add a new payment type, you should be able to write a new class without touching the existing payment processing code. If adding a feature means editing working code, the design is wrong.

Why This Matters in Interviews
OCP is what separates candidates who build rigid systems from those who build flexible ones. When an interviewer says "now add support for cryptocurrency payments," they're testing whether your design can absorb new requirements without modifying existing, tested code.
If your answer is "I'd add another if/else in the PaymentProcessor," you've just demonstrated a design that gets worse with every new feature. If your answer is "I'd create a new CryptoPayment class that implements the PaymentMethod interface," you've shown a design that scales cleanly.
The Problem: If/Else Chains That Never Stop Growing
// BAD: Every new payment type requires modifying this class
class PaymentProcessor {
public void processPayment(String type, double amount) {
if (type.equals("credit_card")) {
System.out.println("Charging credit card: $" + amount);
// Credit card API calls...
// Fraud checks specific to cards...
} else if (type.equals("paypal")) {
System.out.println("Processing PayPal: $" + amount);
// PayPal OAuth flow...
// PayPal-specific refund handling...
} else if (type.equals("bank_transfer")) {
System.out.println("Initiating bank transfer: $" + amount);
// ACH processing...
// 3-day settlement delay logic...
}
// Next month: add crypto? Another else-if.
// Month after: add Apple Pay? Another else-if.
// This class grows forever.
}
}
// BAD: Same problem in C++
class PaymentProcessor {
public:
void processPayment(const std::string& type, double amount) {
if (type == "credit_card") {
std::cout << "Charging credit card: $" << amount << std::endl;
} else if (type == "paypal") {
std::cout << "Processing PayPal: $" << amount << std::endl;
} else if (type == "bank_transfer") {
std::cout << "Initiating bank transfer: $" << amount << std::endl;
}
}
};
# BAD: Same problem in Python
class PaymentProcessor:
def process_payment(self, payment_type: str, amount: float) -> None:
if payment_type == "credit_card":
print(f"Charging credit card: ${amount}")
elif payment_type == "paypal":
print(f"Processing PayPal: ${amount}")
elif payment_type == "bank_transfer":
print(f"Initiating bank transfer: ${amount}")
What's Wrong Here?
Every new payment type requires modifying PaymentProcessor. This means:
- Risk — you're editing tested, working code every time you add a feature
- Merge conflicts — every developer adding a payment type touches the same file
- Testing burden — you have to re-test all payment paths, not just the new one
- Growing complexity — the method gets longer and harder to read with each addition
The Fix: Extend, Don't Modify
// GOOD: Define a contract, then extend with new classes
interface PaymentMethod {
void pay(double amount);
boolean supports(String type);
}
class CreditCardPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Charging credit card: $" + amount);
// Credit card API calls, fraud checks...
}
@Override
public boolean supports(String type) {
return "credit_card".equals(type);
}
}
class PayPalPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing PayPal: $" + amount);
// PayPal OAuth, refund handling...
}
@Override
public boolean supports(String type) {
return "paypal".equals(type);
}
}
// PaymentProcessor is now CLOSED for modification
class PaymentProcessor {
private final List<PaymentMethod> methods;
public PaymentProcessor(List<PaymentMethod> methods) {
this.methods = methods;
}
public void processPayment(String type, double amount) {
PaymentMethod method = methods.stream()
.filter(m -> m.supports(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown payment type: " + type));
method.pay(amount);
}
}
// GOOD: Same approach in C++
class PaymentMethod {
public:
virtual ~PaymentMethod() = default;
virtual void pay(double amount) = 0;
virtual bool supports(const std::string& type) const = 0;
};
class CreditCardPayment : public PaymentMethod {
public:
void pay(double amount) override {
std::cout << "Charging credit card: $" << amount << std::endl;
}
bool supports(const std::string& type) const override {
return type == "credit_card";
}
};
class PayPalPayment : public PaymentMethod {
public:
void pay(double amount) override {
std::cout << "Processing PayPal: $" << amount << std::endl;
}
bool supports(const std::string& type) const override {
return type == "paypal";
}
};
class PaymentProcessor {
private:
std::vector<std::unique_ptr<PaymentMethod>> methods;
public:
PaymentProcessor(std::vector<std::unique_ptr<PaymentMethod>> methods)
: methods(std::move(methods)) {}
void processPayment(const std::string& type, double amount) {
for (const auto& method : methods) {
if (method->supports(type)) {
method->pay(amount);
return;
}
}
throw std::invalid_argument("Unknown payment type: " + type);
}
};
# GOOD: Same approach in Python
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount: float) -> None:
pass
@abstractmethod
def supports(self, payment_type: str) -> bool:
pass
class CreditCardPayment(PaymentMethod):
def pay(self, amount: float) -> None:
print(f"Charging credit card: ${amount}")
def supports(self, payment_type: str) -> bool:
return payment_type == "credit_card"
class PayPalPayment(PaymentMethod):
def pay(self, amount: float) -> None:
print(f"Processing PayPal: ${amount}")
def supports(self, payment_type: str) -> bool:
return payment_type == "paypal"
class PaymentProcessor:
def __init__(self, methods: list[PaymentMethod]):
self._methods = methods
def process_payment(self, payment_type: str, amount: float) -> None:
for method in self._methods:
if method.supports(payment_type):
method.pay(amount)
return
raise ValueError(f"Unknown payment type: {payment_type}")
Now adding cryptocurrency payments means writing one new class:
// Adding a new payment type = ZERO changes to existing code
class CryptoPayment implements PaymentMethod {
@Override
public void pay(double amount) {
System.out.println("Processing crypto payment: $" + amount);
// Blockchain transaction logic...
}
@Override
public boolean supports(String type) {
return "crypto".equals(type);
}
}
No existing class was modified. No existing tests need to change. The PaymentProcessor didn't even know crypto existed, and it works anyway.
Interview tip: This pattern is actually the Strategy pattern in disguise. When you apply OCP by defining an interface and swapping implementations, you're using Strategy. We'll see it again in the design patterns chapter. Recognizing this connection shows depth.
When OCP Doesn't Apply
Not every if/else needs to become an interface. If the set of options is small, stable, and unlikely to change (e.g., checking if a number is positive, negative, or zero), an if/else is fine. OCP pays off when the list of options is expected to grow.
Ask yourself: "Will someone need to add a new case here in six months?" If yes, design for extension. If no, keep it simple.
Interview tip: Don't reflexively apply OCP everywhere. If the interviewer asks you to handle two cases and you build an elaborate plugin architecture, you've over-engineered. Apply OCP when you can see the extension point coming.
Quick Recap
| Concept | What it means | Why it matters |
|---|---|---|
| Closed for modification | Existing, tested code stays untouched when you add features | No risk of breaking working functionality |
| Open for extension | New behavior is added by writing new classes, not editing old ones | Features scale without growing complexity |
| Interface as extension point | Define a contract; new implementations extend behavior | Each implementation is independent and testable |
| Strategy pattern preview | OCP + interfaces = Strategy pattern | Same concept will reappear in design patterns |