You’ve just shipped a critical feature. It’s tested, stable, and running perfectly in production. A week later, a new business requirement comes in that needs to alter the logic of that very feature. What do you do? If your first instinct is to open up that battle-tested class and start changing it, you might be introducing unnecessary risk.
This is where the second principle of SOLID comes into play: the Open/Closed Principle (OCP).
The Open/Closed Principle states that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
In simple terms, you should be able to add new functionality without changing existing code. This minimizes the risk of breaking something that already works and has been proven stable.
Imagine building a house. A house that violates OCP is built like a sealed unit. To add a new floor, you’d have to demolish the roof, risking structural damage to the entire building.
A house that follows OCP, however, is designed with future expansion in mind. The roof is flat and reinforced, ready to serve as the foundation for a new floor. You can extend the house upwards without ever touching the existing, lived-in floors.
graph TD;
subgraph Violation of OCP
A[House v1] -- Demolish Roof --> B(Structural Risk);
B -- Rebuild --> C[House v2 with new floor];
end
subgraph Adherence to OCP
D[House v1 with Extension Point] -- Add New Floor --> E[House v2];
D -- Remains Untouched --> D;
end
Let’s see how this applies to code.
The Problem: A Service That Requires Modification
Imagine we’re building a DiscountService for an e-commerce platform. The initial requirement is to provide discounts for two types of customers: “Regular” (5% discount) and “VIP” (10% discount).
A quick and dirty implementation might look like this:
// src/main/java/com/example/DiscountService.java
public class DiscountService {
public double calculateDiscount(double total, String customerType) {
if ("REGULAR".equalsIgnoreCase(customerType)) {
return total * 0.05; // 5% discount
} else if ("VIP".equalsIgnoreCase(customerType)) {
return total * 0.10; // 10% discount
}
return 0;
}
}
This code works perfectly for the current requirements. We test it, deploy it, and everything is fine.
But then, the business decides to introduce two new customer tiers: “Premium” (15% discount) and “Enterprise” (20% discount).
To implement this, our only option is to modify the calculateDiscount method:
// src/main/java/com/example/DiscountService.java
public class DiscountService {
public double calculateDiscount(double total, String customerType) {
if ("REGULAR".equalsIgnoreCase(customerType)) {
return total * 0.05;
} else if ("VIP".equalsIgnoreCase(customerType)) {
return total * 0.10;
+ } else if ("PREMIUM".equalsIgnoreCase(customerType)) {
+ return total * 0.15; // 15% discount
+ } else if ("ENTERPRISE".equalsIgnoreCase(customerType)) {
+ return total * 0.20; // 20% discount
}
return 0;
}
}
[!WARNING] By modifying a class that was already tested and in production, we’ve created a risk. A small mistake in the new
else ifblocks could potentially break the discount calculation for all customer types, even the ones that were working before. This is called a regression bug.
This design is closed for extension (we can’t add new discount types without changing the code) and open for modification (our only path forward is to change it). This is the exact opposite of what the Open/Closed Principle prescribes.
The Solution: Designing for Extension with the Strategy Pattern
To refactor this to comply with OCP, we can use a powerful design pattern: the Strategy Pattern. The core idea is to define a family of algorithms, encapsulate each one, and make them interchangeable.
Here’s our plan:
- Create an Abstraction: Define an interface that represents the “contract” for any discount strategy.
- Implement Concrete Strategies: Create separate classes for each discount type, with each class implementing the common interface.
- Use the Abstraction: The
DiscountServicewill no longer containif/elselogic. Instead, it will use a discount strategy object to perform the calculation.
Let’s see the code.
Step 1: Define the Strategy Interface
First, we create an interface that all our discount calculators will adhere to.
// src/main/java/com/example/strategy/DiscountStrategy.java
public interface DiscountStrategy {
double applyDiscount(double total);
}
This interface defines a single method, applyDiscount, which takes the total price and returns the discounted amount.
Step 2: Create Concrete Implementations
Next, we create a separate class for each of our existing discount types.
// src/main/java/com/example/strategy/RegularDiscount.java
public class RegularDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double total) {
return total * 0.05; // 5% discount
}
}
// src/main/java/com/example/strategy/VipDiscount.java
public class VipDiscount implements DiscountStrategy {
@Override
public double applyDiscount(double total) {
return total * 0.10; // 10% discount
}
}
Step 3: Refactor the Service to Use the Strategy
Now, we can refactor our DiscountService. Instead of containing the logic itself, it will be given a DiscountStrategy object and will simply use it. This is a form of Dependency Injection.
// src/main/java/com/example/DiscountService.java
import com.example.strategy.DiscountStrategy;
public class DiscountService {
private final DiscountStrategy discountStrategy;
// The strategy is injected via the constructor
public DiscountService(DiscountStrategy discountStrategy) {
this.discountStrategy = discountStrategy;
}
public double calculateDiscount(double total) {
// The service delegates the calculation to the strategy object.
// It doesn't know or care about the specific logic.
return discountStrategy.applyDiscount(total);
}
}
Our new design is much more robust and follows OCP. Here is a visual representation of the new structure:
classDiagram
class DiscountService {
-DiscountStrategy strategy
+calculateDiscount(total)
}
class DiscountStrategy {
<<interface>>
+applyDiscount(total)
}
class RegularDiscount {
+applyDiscount(total)
}
class VipDiscount {
+applyDiscount(total)
}
DiscountService o-- DiscountStrategy
DiscountStrategy <|.. RegularDiscount
DiscountStrategy <|.. VipDiscount
The Payoff: Adding New Features Without Fear
Now, let’s revisit the new business requirement: add “Premium” and “Enterprise” discounts.
With our new design, this is incredibly simple and, more importantly, safe. We don’t touch the DiscountService or any of the existing, tested strategy classes.
We simply create new classes that implement the DiscountStrategy interface.
src/main/java/com/example/strategy/
├── DiscountStrategy.java
├── RegularDiscount.java
├── VipDiscount.java
├── PremiumDiscount.java <-- NEW
└── EnterpriseDiscount.java <-- NEW
Here is the code for the new PremiumDiscount:
// No changes to existing files. We only add a new file.
// src/main/java/com/example/strategy/PremiumDiscount.java
+ public class PremiumDiscount implements DiscountStrategy {
+ @Override
+ public double applyDiscount(double total) {
+ return total * 0.15; // 15% discount
+ }
+ }
Now, when we need to calculate a discount, we just instantiate the DiscountService with the appropriate strategy:
// Main application logic
public static void main(String[] args) {
double orderTotal = 1000.0;
// Calculate discount for a VIP customer
DiscountService vipService = new DiscountService(new VipDiscount());
double vipDiscount = vipService.calculateDiscount(orderTotal);
System.out.println("VIP Discount: " + vipDiscount); // Prints: VIP Discount: 100.0
// Calculate discount for a new Premium customer
DiscountService premiumService = new DiscountService(new PremiumDiscount());
double premiumDiscount = premiumService.calculateDiscount(orderTotal);
System.out.println("Premium Discount: " + premiumDiscount); // Prints: Premium Discount: 150.0
}
We have successfully added new functionality without modifying any existing code. Our DiscountService is now open for extension but closed for modification.
Summary and Best Practices
mindmap
root((Open/Closed Principle))
Open for Extension
::icon(fa fa-plus-circle)
Adding new features
Via interfaces & polymorphism
"Example: Strategy Pattern"
No risk to existing code
Closed for Modification
::icon(fa fa-lock)
Existing, tested code is sacred
Prevents regression bugs
Improves stability & maintainability
Achieved through abstraction
[!TIP] Best Practices for OCP:
- Abstraction is Key: Use interfaces and abstract classes to define contracts that can be extended.
- Dependency Injection: Pass dependencies (like our
DiscountStrategy) into classes rather than having them create instances themselves.- Think Ahead: When designing a class, consider what aspects are most likely to change in the future. Those are prime candidates for abstraction.
Quiz: Test Your Knowledge
A new requirement has come in to add a "Student" discount of 25%. Based on the OCP-compliant design we just created, what is the correct course of action?
- Modify the
DiscountServiceto add a new constructor. - Create a new file named
StudentDiscount.javathat implements theDiscountStrategyinterface. - Add an
else ifblock to one of the existing strategy classes.
Show Answer
Correct Answer: 2. The correct approach is to create a new StudentDiscount.java class. This extends the system's functionality without modifying any existing code, perfectly adhering to the Open/Closed Principle.