SOLID Principles Series #2
InPost just changed their pricing model. Again. You open ShippingCalculator, find the switch statement, locate the InPost branch, and carefully edit the formula. While you’re in there, you notice the DHL branch looks suspicious too. You touch it. Now two things are broken instead of one. This is the cost of code that is never…
What Is the Open/Closed Principle?
OCP is the second of the five SOLID principles, also formulated by Robert C. Martin, building on earlier work by Bertrand Meyer. The definition: “Software entities should be open for extension, but closed for modification.”
Open for extension means new behaviour can be added. Closed for modification means existing, tested code does not need to change to accommodate it. The two goals sound contradictory until you see how abstraction makes them compatible.
In practice: instead of adding a new branch to an existing method, you add a new class that plugs into an existing interface. The method never changes. The interface never changes. Only the set of implementations grows.
The Problem in Code
A shipping calculator handles three carriers. The logic lives in one method:
// ShippingCalculator.java - closed for extension, open for modification
public class ShippingCalculator {
public double calculate(String carrier, double weightKg) {
switch (carrier) {
case "INPOST":
return weightKg * 2.5 + 1.99;
case "DHL":
return weightKg * 3.8 + 4.50;
case "DPD":
return weightKg * 3.2 + 3.00;
default:
throw new IllegalArgumentException("Unknown carrier: " + carrier);
}
}
}
InPost changes their rates. You open this file and edit. A new carrier arrives (GLS, FedEx, your own courier fleet). You open this file and add a case. Every change is a modification to tested, deployed code. Every modification is a regression risk for all the other carriers sitting in the same switch.
The Solution, Step by Step
The fix follows the same move as Strategy from the design patterns series: extract the varying behaviour behind an interface, and let the calculator work against the abstraction.
1. Define the extension point
The interface is the contract. It defines what every carrier must provide. This is the part that stays closed: once defined, it does not change.
// ShippingProvider.java
public interface ShippingProvider {
double calculate(double weightKg);
String carrierName();
}
2. Implement each carrier as its own class
Each carrier owns its own pricing logic. InPost’s formula is in InPostProvider and nowhere else. When InPost changes their rates, this is the only file that opens.
// InPostProvider.java
public class InPostProvider implements ShippingProvider {
@Override
public double calculate(double weightKg) {
return weightKg * 2.5 + 1.99;
}
@Override
public String carrierName() {
return "InPost";
}
}
// DhlProvider.java
public class DhlProvider implements ShippingProvider {
@Override
public double calculate(double weightKg) {
return weightKg * 3.8 + 4.50;
}
@Override
public String carrierName() {
return "DHL";
}
}
// DpdProvider.java
public class DpdProvider implements ShippingProvider {
@Override
public double calculate(double weightKg) {
return weightKg * 3.2 + 3.00;
}
@Override
public String carrierName() {
return "DPD";
}
}
3. Rewrite the calculator to work against the interface
ShippingCalculator no longer knows about InPost, DHL, or DPD. It knows about ShippingProvider. That’s the part that is now closed for modification.
// ShippingCalculator.java - open for extension, closed for modification
public class ShippingCalculator {
private final List<ShippingProvider> providers;
public ShippingCalculator(List<ShippingProvider> providers) {
this.providers = providers;
}
public double calculate(String carrierName, double weightKg) {
return providers.stream()
.filter(p -> p.carrierName().equalsIgnoreCase(carrierName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown carrier: " + carrierName))
.calculate(weightKg);
}
}
4. Add a new carrier without touching anything
GLS joins the platform. Here is the entire change required:
// GlsProvider.java - the only new file needed
public class GlsProvider implements ShippingProvider {
@Override
public double calculate(double weightKg) {
return weightKg * 2.9 + 2.50;
}
@Override
public String carrierName() {
return "GLS";
}
}
// Wiring - register the new provider at startup
List<ShippingProvider> providers = List.of(
new InPostProvider(),
new DhlProvider(),
new DpdProvider(),
new GlsProvider() // added here, nothing else changes
);
ShippingCalculator calculator = new ShippingCalculator(providers);
double cost = calculator.calculate("GLS", 3.5);
ShippingCalculator was not opened. ShippingProvider was not opened. The existing carrier classes were not opened. One new class, registered in one place. That’s OCP.
Why This Actually Works
Existing code stays tested. Adding GLS does not touch InPost, DHL, or DPD. Their unit tests still pass because their code did not change. You are not retesting things that were not modified.
Risk is contained. In the original design, editing the switch for InPost meant being one typo away from breaking DHL. Now a bug in GlsProvider can only affect GLS. The blast radius of any mistake is limited to the class that changed.
The connection to Strategy. If this looks familiar, it should. The structure here is almost identical to the Strategy pattern from the design patterns series. OCP is the principle; Strategy is one of the patterns that implements it. Understanding the principle explains why the pattern works, not just how to apply it.
When to Apply It and When to Pause
Apply OCP when:
- A switch or if-else grows every time a new variant is added.
- Different teams or business units own different branches of the same logic.
- You want to add behaviour without risking existing, tested code.
Pause before applying OCP when:
- You only have one variant today with no realistic expectation of more.
- The abstraction would be more complex than the switch it replaces.
- You’re designing for imaginary future requirements (YAGNI applies here too).
OCP is not a rule to apply upfront everywhere. It’s a response to a pattern you’ve seen: the same class keeps getting opened for the same kind of change. When that happens, the abstraction has earned its place.
Wrapping Up
The original ShippingCalculator was not broken. It worked. The problem only became visible when the third carrier arrived, and then the fourth, and then InPost changed their pricing for the second time. OCP is the principle that turns that recurring pain into a one-time design decision.
Next up: the Liskov Substitution Principle, where we look at what it actually means for a subclass to be a proper replacement for its parent, and why getting this wrong breaks polymorphism in ways that are surprisingly hard to debug.
Have you seen a switch statement that kept growing? Drop a comment below. I’m curious how you handled it: did you refactor proactively or wait until the pain was bad enough to justify it? There’s no universally right answer, and the reasoning behind the decision is usually more interesting than the decision itself.