O (Open/Closed) from SOLID
The Open/Closed Principle: How to Add Features Without Breaking Everything
What Does “Open/Closed” Even Mean?
Here’s a riddle for you: How can something be both open AND closed at the same time?
Welcome to the Open/Closed Principle (OCP), the second letter in SOLID and probably the one that causes the most head-scratching when you first hear about it. Bertrand Meyer coined this principle back in 1988, stating that software entities should be open for extension but closed for modification.
Let me translate that into English: Your code should be designed so that when new requirements come in (and they always do), you can add new functionality without changing the existing code that already works.
Think of it like this: imagine you bought a new phone. When a new app comes out, you don’t need to rebuild your phone to use it—you just install the app. Your phone is “closed for modification” (you’re not rewiring it) but “open for extension” (you can add apps). That’s OCP in a nutshell.
Why Should You Care?
Let me tell you about a nightmare I experienced early in my career. We had a payment processing system that handled credit card payments. It worked great. Then the business wanted to add PayPal support. No problem, right?
Wrong.
The developer who built it had hardcoded credit card logic throughout the entire system. Adding PayPal meant changing dozens of files, updating switch statements, modifying if-else chains, and praying nothing broke. We spent three weeks on what should have been a two-day task, and introduced five production bugs in the process.
If the original code had followed OCP, we could have just added a new PayPalPayment class and plugged it in. No changes to existing code. No bugs in the working credit card system. No three-week death march.
Here’s why OCP matters:
1. Reduced Risk
When you’re not modifying working code, you can’t break it. Every time you edit a file that’s in production, you’re rolling the dice.
2. Faster Development
Adding features becomes a matter of creating new code, not navigating and changing old code. This is exponentially faster.
3. Easier Testing
New functionality gets new tests. You don’t have to re-verify all the old functionality because you haven’t touched it.
4. Better Parallel Development
Multiple developers can add different features simultaneously without merge conflicts, because they’re creating new files instead of editing the same old ones.
5. Simplified Code Reviews
Reviewers can focus on the new code without worrying about side effects on existing functionality.
A Real-World Example: The Payment Processor
Let me show you exactly what I mean with a concrete example you’ll recognize.
The Wrong Way: Violating OCP
Here’s how most beginners (and sadly, many experienced developers) build a payment system:
public class PaymentProcessor {
public void processPayment(String paymentType, double amount, PaymentDetails details) {
if (paymentType.equals("CREDIT_CARD")) {
processCreditCard(amount, details);
} else if (paymentType.equals("PAYPAL")) {
processPayPal(amount, details);
} else if (paymentType.equals("BITCOIN")) {
processBitcoin(amount, details);
} else if (paymentType.equals("BANK_TRANSFER")) {
processBankTransfer(amount, details);
} else {
throw new IllegalArgumentException("Unknown payment type: " + paymentType);
}
}
private void processCreditCard(double amount, PaymentDetails details) {
System.out.println("Processing credit card payment");
// Validate card number
if (!details.getCardNumber().matches("\\d{16}")) {
throw new InvalidPaymentException("Invalid card number");
}
// Check expiration
if (details.getExpirationDate().isBefore(LocalDate.now())) {
throw new InvalidPaymentException("Card expired");
}
// Process with payment gateway
CreditCardGateway gateway = new CreditCardGateway();
gateway.charge(details.getCardNumber(), amount);
System.out.println("Credit card payment completed");
}
private void processPayPal(double amount, PaymentDetails details) {
System.out.println("Processing PayPal payment");
// Validate PayPal account
if (details.getPayPalEmail() == null || !details.getPayPalEmail().contains("@")) {
throw new InvalidPaymentException("Invalid PayPal email");
}
// Process through PayPal API
PayPalAPI api = new PayPalAPI();
api.sendPayment(details.getPayPalEmail(), amount);
System.out.println("PayPal payment completed");
}
private void processBitcoin(double amount, PaymentDetails details) {
System.out.println("Processing Bitcoin payment");
// Validate Bitcoin address
if (details.getBitcoinAddress() == null || details.getBitcoinAddress().length() < 26) {
throw new InvalidPaymentException("Invalid Bitcoin address");
}
// Process blockchain transaction
BlockchainService blockchain = new BlockchainService();
blockchain.transfer(details.getBitcoinAddress(), amount);
System.out.println("Bitcoin payment completed");
}
private void processBankTransfer(double amount, PaymentDetails details) {
System.out.println("Processing bank transfer");
// Validate account details
if (details.getAccountNumber() == null || details.getRoutingNumber() == null) {
throw new InvalidPaymentException("Invalid bank details");
}
// Process through banking system
BankingService bank = new BankingService();
bank.initiateTransfer(details.getAccountNumber(), details.getRoutingNumber(), amount);
System.out.println("Bank transfer completed");
}
}
This looks reasonable at first glance, but watch what happens when business says: “We need to support Apple Pay, Google Pay, and cryptocurrency wallets.”
You have to:
- Open the
PaymentProcessorclass - Add more if-else statements
- Add more private methods
- Test everything again (because you modified existing code)
- Deploy and pray
Every new payment method means modifying this class. It violates OCP because it’s not closed for modification.
The Right Way: Following OCP
Now let’s redesign this system using OCP principles:
1. Define the Abstraction
First, we create an interface that defines what a payment method can do:
public interface PaymentMethod {
PaymentResult process(double amount, PaymentDetails details);
boolean validate(PaymentDetails details);
String getPaymentType();
}
2. Implement Specific Payment Methods
Now each payment type is its own class:
public class CreditCardPayment implements PaymentMethod {
private final CreditCardGateway gateway;
public CreditCardPayment(CreditCardGateway gateway) {
this.gateway = gateway;
}
@Override
public PaymentResult process(double amount, PaymentDetails details) {
try {
if (!validate(details)) {
return PaymentResult.failure("Invalid credit card details");
}
String transactionId = gateway.charge(details.getCardNumber(), amount);
return PaymentResult.success(transactionId, amount, "Credit Card");
} catch (Exception e) {
return PaymentResult.failure("Credit card processing failed: " + e.getMessage());
}
}
@Override
public boolean validate(PaymentDetails details) {
// Validate card number (16 digits)
if (details.getCardNumber() == null || !details.getCardNumber().matches("\\d{16}")) {
return false;
}
// Check expiration date
if (details.getExpirationDate() == null ||
details.getExpirationDate().isBefore(LocalDate.now())) {
return false;
}
// Validate CVV
if (details.getCvv() == null || !details.getCvv().matches("\\d{3,4}")) {
return false;
}
return true;
}
@Override
public String getPaymentType() {
return "CREDIT_CARD";
}
}
public class PayPalPayment implements PaymentMethod {
private final PayPalAPI paypalApi;
public PayPalPayment(PayPalAPI paypalApi) {
this.paypalApi = paypalApi;
}
@Override
public PaymentResult process(double amount, PaymentDetails details) {
try {
if (!validate(details)) {
return PaymentResult.failure("Invalid PayPal details");
}
String transactionId = paypalApi.sendPayment(details.getPayPalEmail(), amount);
return PaymentResult.success(transactionId, amount, "PayPal");
} catch (Exception e) {
return PaymentResult.failure("PayPal processing failed: " + e.getMessage());
}
}
@Override
public boolean validate(PaymentDetails details) {
String email = details.getPayPalEmail();
return email != null &&
email.contains("@") &&
email.contains(".");
}
@Override
public String getPaymentType() {
return "PAYPAL";
}
}
public class BitcoinPayment implements PaymentMethod {
private final BlockchainService blockchain;
public BitcoinPayment(BlockchainService blockchain) {
this.blockchain = blockchain;
}
@Override
public PaymentResult process(double amount, PaymentDetails details) {
try {
if (!validate(details)) {
return PaymentResult.failure("Invalid Bitcoin address");
}
String transactionHash = blockchain.transfer(details.getBitcoinAddress(), amount);
return PaymentResult.success(transactionHash, amount, "Bitcoin");
} catch (Exception e) {
return PaymentResult.failure("Bitcoin processing failed: " + e.getMessage());
}
}
@Override
public boolean validate(PaymentDetails details) {
String address = details.getBitcoinAddress();
// Bitcoin addresses are 26-35 characters
return address != null &&
address.length() >= 26 &&
address.length() <= 35;
}
@Override
public String getPaymentType() {
return "BITCOIN";
}
}
public class BankTransferPayment implements PaymentMethod {
private final BankingService bankingService;
public BankTransferPayment(BankingService bankingService) {
this.bankingService = bankingService;
}
@Override
public PaymentResult process(double amount, PaymentDetails details) {
try {
if (!validate(details)) {
return PaymentResult.failure("Invalid bank account details");
}
String referenceNumber = bankingService.initiateTransfer(
details.getAccountNumber(),
details.getRoutingNumber(),
amount
);
return PaymentResult.success(referenceNumber, amount, "Bank Transfer");
} catch (Exception e) {
return PaymentResult.failure("Bank transfer failed: " + e.getMessage());
}
}
@Override
public boolean validate(PaymentDetails details) {
return details.getAccountNumber() != null &&
details.getRoutingNumber() != null &&
details.getAccountNumber().matches("\\d{8,17}") &&
details.getRoutingNumber().matches("\\d{9}");
}
@Override
public String getPaymentType() {
return "BANK_TRANSFER";
}
}
3. Create the Payment Processor
Now our processor is beautifully simple:
public class PaymentProcessor {
private final Map<String, PaymentMethod> paymentMethods;
public PaymentProcessor(List<PaymentMethod> methods) {
this.paymentMethods = methods.stream()
.collect(Collectors.toMap(
PaymentMethod::getPaymentType,
method -> method
));
}
public PaymentResult processPayment(String paymentType, double amount, PaymentDetails details) {
PaymentMethod method = paymentMethods.get(paymentType);
if (method == null) {
return PaymentResult.failure("Unsupported payment type: " + paymentType);
}
return method.process(amount, details);
}
public Set<String> getSupportedPaymentTypes() {
return paymentMethods.keySet();
}
}
4. Supporting Classes
public class PaymentResult {
private final boolean success;
private final String transactionId;
private final double amount;
private final String paymentType;
private final String errorMessage;
private final LocalDateTime timestamp;
private PaymentResult(boolean success, String transactionId, double amount,
String paymentType, String errorMessage) {
this.success = success;
this.transactionId = transactionId;
this.amount = amount;
this.paymentType = paymentType;
this.errorMessage = errorMessage;
this.timestamp = LocalDateTime.now();
}
public static PaymentResult success(String transactionId, double amount, String paymentType) {
return new PaymentResult(true, transactionId, amount, paymentType, null);
}
public static PaymentResult failure(String errorMessage) {
return new PaymentResult(false, null, 0.0, null, errorMessage);
}
// Getters
public boolean isSuccess() { return success; }
public String getTransactionId() { return transactionId; }
public double getAmount() { return amount; }
public String getPaymentType() { return paymentType; }
public String getErrorMessage() { return errorMessage; }
public LocalDateTime getTimestamp() { return timestamp; }
}
public class PaymentDetails {
// Credit card fields
private String cardNumber;
private LocalDate expirationDate;
private String cvv;
// PayPal fields
private String payPalEmail;
// Bitcoin fields
private String bitcoinAddress;
// Bank transfer fields
private String accountNumber;
private String routingNumber;
// Getters and setters
public String getCardNumber() { return cardNumber; }
public void setCardNumber(String cardNumber) { this.cardNumber = cardNumber; }
public LocalDate getExpirationDate() { return expirationDate; }
public void setExpirationDate(LocalDate expirationDate) { this.expirationDate = expirationDate; }
public String getCvv() { return cvv; }
public void setCvv(String cvv) { this.cvv = cvv; }
public String getPayPalEmail() { return payPalEmail; }
public void setPayPalEmail(String payPalEmail) { this.payPalEmail = payPalEmail; }
public String getBitcoinAddress() { return bitcoinAddress; }
public void setBitcoinAddress(String bitcoinAddress) { this.bitcoinAddress = bitcoinAddress; }
public String getAccountNumber() { return accountNumber; }
public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
public String getRoutingNumber() { return routingNumber; }
public void setRoutingNumber(String routingNumber) { this.routingNumber = routingNumber; }
}
5. Usage Example
Here’s how you’d use this in your application:
public class PaymentService {
private final PaymentProcessor processor;
public PaymentService() {
// Set up all payment methods
List<PaymentMethod> methods = Arrays.asList(
new CreditCardPayment(new CreditCardGateway()),
new PayPalPayment(new PayPalAPI()),
new BitcoinPayment(new BlockchainService()),
new BankTransferPayment(new BankingService())
);
this.processor = new PaymentProcessor(methods);
}
public PaymentResult pay(String paymentType, double amount, PaymentDetails details) {
return processor.processPayment(paymentType, amount, details);
}
}
// In your application
public class EcommerceController {
private final PaymentService paymentService;
public Response checkout(CheckoutRequest request) {
PaymentDetails details = new PaymentDetails();
details.setCardNumber(request.getCardNumber());
details.setExpirationDate(request.getExpirationDate());
details.setCvv(request.getCvv());
PaymentResult result = paymentService.pay("CREDIT_CARD", request.getAmount(), details);
if (result.isSuccess()) {
return Response.ok()
.entity(new CheckoutResponse(result.getTransactionId()))
.build();
} else {
return Response.status(400)
.entity(new ErrorResponse(result.getErrorMessage()))
.build();
}
}
}
Now, Watch the Magic
When business comes back and says “We need to support Apple Pay,” here’s what you do:
public class ApplePayPayment implements PaymentMethod {
private final ApplePayAPI applePayApi;
public ApplePayPayment(ApplePayAPI applePayApi) {
this.applePayApi = applePayApi;
}
@Override
public PaymentResult process(double amount, PaymentDetails details) {
try {
if (!validate(details)) {
return PaymentResult.failure("Invalid Apple Pay token");
}
String transactionId = applePayApi.processPayment(details.getApplePayToken(), amount);
return PaymentResult.success(transactionId, amount, "Apple Pay");
} catch (Exception e) {
return PaymentResult.failure("Apple Pay processing failed: " + e.getMessage());
}
}
@Override
public boolean validate(PaymentDetails details) {
return details.getApplePayToken() != null &&
!details.getApplePayToken().isEmpty();
}
@Override
public String getPaymentType() {
return "APPLE_PAY";
}
}
That’s it. You:
- Created one new class
- Added it to the list of payment methods
- Didn’t touch any existing code
- Didn’t break anything that was working
The existing payment methods (credit card, PayPal, Bitcoin, bank transfer) continue to work exactly as before because you didn’t modify them. You extended the system’s capabilities without changing existing code.
The Key Benefits
Let’s break down what we gained:
1. Zero Risk to Existing Functionality
The credit card payment code hasn’t changed. It’s been tested, it works in production, and it will continue to work. No regression bugs.
2. Easy Testing
@Test
public void testApplePayPayment() {
ApplePayAPI mockApi = mock(ApplePayAPI.class);
when(mockApi.processPayment(anyString(), anyDouble()))
.thenReturn("APPLE_PAY_TRANS_123");
PaymentMethod applePay = new ApplePayPayment(mockApi);
PaymentDetails details = new PaymentDetails();
details.setApplePayToken("valid_token");
PaymentResult result = applePay.process(100.00, details);
assertTrue(result.isSuccess());
assertEquals("APPLE_PAY_TRANS_123", result.getTransactionId());
}
We only test the new Apple Pay code. We don’t need to re-test credit cards, PayPal, or anything else.
3. Parallel Development
One developer can work on Apple Pay while another works on Google Pay, and a third works on crypto wallets. No merge conflicts because everyone is creating new files.
4. Configuration-Based Extension
You can even make payment methods configurable:
@Configuration
public class PaymentConfig {
@Bean
public PaymentProcessor paymentProcessor() {
List<PaymentMethod> methods = new ArrayList<>();
// Always include basic methods
methods.add(new CreditCardPayment(creditCardGateway()));
methods.add(new BankTransferPayment(bankingService()));
// Conditionally add based on configuration
if (config.isPayPalEnabled()) {
methods.add(new PayPalPayment(payPalApi()));
}
if (config.isCryptoEnabled()) {
methods.add(new BitcoinPayment(blockchainService()));
}
if (config.isApplePayEnabled()) {
methods.add(new ApplePayPayment(applePayApi()));
}
return new PaymentProcessor(methods);
}
}
Now you can enable or disable payment methods through configuration without changing code.
Real-World Examples from Java
1. Java Collections Framework
The Collections Framework is a masterpiece of OCP:
// The List interface is closed for modification
public interface List<E> extends Collection<E> {
boolean add(E e);
E get(int index);
// ... other methods
}
// But open for extension - you can create new implementations
public class ArrayList<E> implements List<E> { /* ... */ }
public class LinkedList<E> implements List<E> { /* ... */ }
public class CopyOnWriteArrayList<E> implements List<E> { /* ... */ }
// Even you can create your own!
public class MySpecialList<E> implements List<E> {
// Your custom implementation
}
The List interface hasn’t changed in years, but new implementations keep appearing. That’s OCP in action.
2. Java I/O Streams
The decorator pattern in Java I/O is pure OCP:
// Base abstraction
InputStream input = new FileInputStream("file.txt");
// Extended with buffering (no modification to FileInputStream)
input = new BufferedInputStream(input);
// Extended with data reading (no modification to either previous class)
DataInputStream dataInput = new DataInputStream(input);
Each decorator adds functionality without modifying the classes it wraps.
3. Servlet Filters
Java web applications use OCP for request processing:
public interface Filter {
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain);
}
// You extend functionality by adding filters
public class AuthenticationFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// Add authentication logic
chain.doFilter(request, response);
}
}
public class LoggingFilter implements Filter {
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
// Add logging logic
chain.doFilter(request, response);
}
}
The servlet container doesn’t change, but you extend its behavior by adding filters.
Common Mistakes and How to Avoid Them
Mistake #1: Premature Abstraction
Don’t create abstractions until you need them:
// DON'T do this if you only have one payment method
public interface PaymentMethod { /* ... */ }
public class CreditCardPayment implements PaymentMethod { /* ... */ }
// Just do this
public class CreditCardPayment { /* ... */ }
Wait until you have a second payment method, then refactor. Following OCP doesn’t mean predicting every possible future requirement.
Mistake #2: Over-Engineering
Not everything needs to follow OCP:
// This is overkill for a simple utility
public interface StringFormatter {
String format(String input);
}
public class UpperCaseFormatter implements StringFormatter { /* ... */ }
// Just use:
public class StringUtils {
public static String toUpperCase(String input) {
return input.toUpperCase();
}
}
Use OCP when you expect variation. Don’t use it for stable, unlikely-to-change functionality.
Mistake #3: Incorrect Abstraction Level
Make sure your abstraction captures the right concept:
// TOO SPECIFIC - This forces all payments to have a card number
public interface PaymentMethod {
void processCardPayment(String cardNumber, double amount);
}
// BETTER - This works for all payment types
public interface PaymentMethod {
PaymentResult process(double amount, PaymentDetails details);
}
The abstraction should be general enough to accommodate different implementations.
Mistake #4: Forgetting About Dependencies
OCP isn’t just about class structure:
// WRONG - Hard dependency on concrete class
public class CreditCardPayment implements PaymentMethod {
public PaymentResult process(double amount, PaymentDetails details) {
// Directly creating dependency
CreditCardGateway gateway = new CreditCardGateway();
gateway.charge(details.getCardNumber(), amount);
}
}
// RIGHT - Inject dependencies
public class CreditCardPayment implements PaymentMethod {
private final CreditCardGateway gateway;
public CreditCardPayment(CreditCardGateway gateway) {
this.gateway = gateway;
}
public PaymentResult process(double amount, PaymentDetails details) {
gateway.charge(details.getCardNumber(), amount);
}
}
Use dependency injection to maintain flexibility.
When to Apply OCP
Apply OCP when:
-
You have known variation points: Payment methods, notification channels, report formats, etc.
-
Requirements are likely to change: Business rules, workflows, integrations.
-
You’re building a framework or library: Others will need to extend your code.
-
You have a plugin architecture: Users add functionality through plugins.
Don’t apply OCP when:
-
The code is stable: If it hasn’t changed in years, it probably won’t.
-
There’s only one implementation: Wait for the second one.
-
The variation is internal: If users don’t see or care about the variation, a simple if-statement might be fine.
-
Performance is critical: Abstraction has a (tiny) cost. In tight loops, direct calls might be better.
Practical Steps to Apply OCP
-
Identify variation points: Where do requirements change most often?
-
Extract the interface: What behavior do all variations share?
-
Create concrete implementations: One class per variation.
-
Use dependency injection: Pass implementations in, don’t create them inside.
-
Refactor incrementally: You don’t have to do it all at once.
Here’s a refactoring checklist:
- Identify the changing behavior
- Create an interface or abstract class
- Move one implementation to a separate class
- Test that it still works
- Move other implementations
- Update the calling code
- Remove the old if-else or switch statement
Testing OCP Code
The beautiful thing about OCP is how testable it makes your code:
@Test
public void testPaymentProcessor_WithMultiplePaymentMethods() {
// Create mock payment methods
PaymentMethod creditCard = mock(PaymentMethod.class);
PaymentMethod paypal = mock(PaymentMethod.class);
when(creditCard.getPaymentType()).thenReturn("CREDIT_CARD");
when(paypal.getPaymentType()).thenReturn("PAYPAL");
when(creditCard.process(anyDouble(), any()))
.thenReturn(PaymentResult.success("CC_123", 100.0, "CREDIT_CARD"));
when(paypal.process(anyDouble(), any()))
.thenReturn(PaymentResult.success("PP_456", 50.0, "PAYPAL"));
PaymentProcessor processor = new PaymentProcessor(Arrays.asList(creditCard, paypal));
// Test credit card
PaymentResult result1 = processor.processPayment("CREDIT_CARD", 100.0, new PaymentDetails());
assertTrue(result1.isSuccess());
assertEquals("CC_123", result1.getTransactionId());
// Test PayPal
PaymentResult result2 = processor.processPayment("PAYPAL", 50.0, new PaymentDetails());
assertTrue(result2.isSuccess());
assertEquals("PP_456", result2.getTransactionId());
// Test unsupported type
PaymentResult result3 = processor.processPayment("VENMO", 25.0, new PaymentDetails());
assertFalse(result3.isSuccess());
}
Each payment method can be tested independently, and the processor can be tested with mocks. Clean, simple, effective.
Conclusion
The Open/Closed Principle might sound like a paradox, but it’s actually straightforward once you get it: design your code so that adding new features doesn’t require changing existing, working code.
Think of it as building with Lego blocks. When you want to build something new, you add blocks—you don’t melt down and reshape the blocks you already have.
The key is identifying where variation will occur and creating the right abstractions around those variation points. Not every if-statement needs to become a Strategy pattern, but when you see the same if-else growing with each new requirement, that’s your cue to apply OCP.
Here’s the bottom line: Good code is easy to extend, not easy to modify. When you follow OCP, adding features becomes a joy instead of a chore, and your existing functionality stays stable and reliable.
Remember: You’re not predicting the future. You’re making your code flexible enough to handle it when it arrives. And trust me, it always arrives.
Continue reading
Next article
S (Single Responsibility) from SOLID
Related Content
I (Interface Segregation) from SOLID
Learn the Interface Segregation Principle from SOLID. Discover why fat interfaces force unnecessary dependencies and how to design focused, client-specific interfaces.
S (Single Responsibility) from SOLID
Understand the Single Responsibility Principle from SOLID. Learn why a class should have only one reason to change and how SRP leads to more maintainable, testable code.
D (Dependency Inversion) from SOLID
Understand the Dependency Inversion Principle from SOLID. Learn why high-level modules should depend on abstractions, not concrete implementations, and how DIP enables testable, flexible architecture.