The Open-Closed Principle
The Principle
Bertrand Meyer introduced the Open-Closed Principle (OCP) in 1988:
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
This means you should be able to add new behaviour to a system without editing existing, working, tested code. Every modification to existing code risks introducing bugs — the fewer lines you touch, the fewer opportunities for breakage.
OCP was popularised as part of the SOLID principles by Robert C. Martin.
The Violation: instanceof Chains
The classic OCP violation is branching on type with instanceof:
class AreaCalculator {
double totalArea(Object[] shapes) {
double total = 0;
for (Object o : shapes) {
if (o instanceof Circle c) {
total += Math.PI * c.radius * c.radius;
} else if (o instanceof Square s) {
total += s.side * s.side;
} else if (o instanceof Triangle t) {
total += 0.5 * t.base * t.height;
}
// MUST be edited for every new shape
}
return total;
}
}
Adding a new shape (Hexagon, Ellipse, etc.) requires finding and editing AreaCalculator.totalArea() — and every other method in the codebase that branches on shape type. In a large system, it is easy to miss one of these branches, leading to bugs or silent incorrect behaviour.
This is also known as the expression problem: how do you add both new operations and new types to a system without modifying existing code? OCP solves the “new types” direction.
The Solution: Polymorphism
The OCP solution is to define an abstraction — an abstract class or interface — that declares the operation, and let each concrete type provide its own implementation:
interface Shape {
double area();
}
record Circle(double radius) implements Shape {
@Override
public double area() { return Math.PI * radius * radius; }
}
record Square(double side) implements Shape {
@Override
public double area() { return side * side; }
}
record Triangle(double base, double height) implements Shape {
@Override
public double area() { return 0.5 * base * height; }
}
Now the calculator is closed for modification:
class AreaCalculator {
double totalArea(Shape[] shapes) {
double total = 0;
for (Shape s : shapes) {
total += s.area(); // no instanceof, no branching
}
return total;
}
}
Adding a new shape — say, record Hexagon(double side) implements Shape — requires zero changes to AreaCalculator. The new class just needs to implement area(). The system is open for adding new shapes and closed for modifying the calculator.
What Makes the Solution Work?
- Abstraction:
Shapedefines a contract (area()) that all shapes must fulfil. New shape types implement this contract. - Polymorphism: code written against
Shapeworks with any current or future implementation. The dispatch to the correctarea()method is automatic and runtime-resolved. - Inheritance/Interfaces: the relationship between the abstraction and its implementations is declared explicitly via
implements Shape.
OCP at Different Scales
OCP applies at many levels of granularity:
| Level | Example |
|---|---|
| Method | Override a method to extend behaviour without editing the superclass |
| Class | Implement an interface to plug into existing polymorphic code |
| Module/Package | Expose interfaces in a public API; new implementations plug in without changing the module |
| System | Plugin architectures (Eclipse, IntelliJ, VS Code) where extensions contribute behaviour via well-defined extension points |
Strategic Closure
In practice, you cannot make every part of a system closed against all possible future changes — that way lies over-engineering. You make strategic choices about the most likely axes of change and design abstractions to accommodate those.
If your system manipulates shapes and the most common change is adding new shape types, you design an abstraction (Shape) that makes adding shapes easy (OCP). If the most common change is adding new operations on shapes (render, serialise, validate), you might instead use the Visitor pattern, which optimises for adding operations at the cost of making it harder to add types.
Robert C. Martin calls this “strategic closure”: close the system against the changes that are most likely and most costly.
OCP and Testing
OCP has a direct benefit for testing: because existing code is not modified when new behaviour is added, existing tests continue to pass. You only need to write tests for the new code. If instead you edited a central branching method to add a new shape, you would need to re-test that entire method — and retest every method that calls it indirectly.
When OCP Is Inappropriate
Applying OCP too early leads to over-designed, over-abstracted code. Adding an interface and multiple implementations when only one concrete class is needed is premature abstraction — it adds complexity without immediate benefit. The guideline is:
- Write the simplest thing that works, likely with a single concrete class.
- When a second implementation is needed, then refactor to extract the abstraction.
- The third implementation validates the abstraction’s design.
This is sometimes called the “Rule of Three”: the first time you do something, just do it. The second time, copy and suffer the duplication. The third time, refactor and abstract.
But — in a course context, you will be shown the refactored design from the start. Understand that the abstraction is added in anticipation of variation; in real projects, the timing of that abstraction is a judgement call.