The SOLID open-closed principle says software components should be open for extension but closed for modification.
In practical Java development, that means you should be able to add new behavior without repeatedly editing stable, tested code. Interfaces, polymorphism and composition are common ways to achieve this.
The word extension does not mean you must use class inheritance. In many Java applications, programming to an interface provides a simpler and more flexible design.
An open-closed principle violation
Imagine an application that compares the areas of two squares. A first implementation might look like this:
final class Square {
private final double side;
Square(double side) {
this.side = side;
}
double area() {
return side * side;
}
}
final class AreaComparator {
int compare(Square first, Square second) {
return Double.compare(first.area(), second.area());
}
}This works perfectly well while squares are the only shapes in the application. The problem appears when the requirements change.
The extension problem
Suppose the application now needs circles. If AreaComparator depends directly on Square, developers might add another comparison method:
final class Circle {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
double area() {
return Math.PI * radius * radius;
}
}
final class AreaComparator {
int compare(Square first, Square second) {
return Double.compare(first.area(), second.area());
}
int compare(Circle first, Circle second) {
return Double.compare(first.area(), second.area());
}
}Now every new shape potentially requires another modification to AreaComparator. Add triangles, rectangles and ellipses, and the comparison component continues to grow.
That is the design pressure the open-closed principle attempts to eliminate.
Apply the open-closed principle with a Java interface
The comparison logic does not actually care whether an object is a square or a circle. It only needs an object capable of calculating an area.
We can express that requirement with an interface:
interface Shape {
double area();
}The concrete shapes implement the interface:
final class Square implements Shape {
private final double side;
Square(double side) {
this.side = side;
}
@Override
public double area() {
return side * side;
}
}
final class Circle implements Shape {
private final double radius;
Circle(double radius) {
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}Notice that area() returns double. The original example used int, which cannot correctly represent a circle's area in the general case.
Create an extensible area comparator
With both classes represented by the Shape abstraction, the comparator needs only one method:
final class AreaComparator {
int compare(Shape first, Shape second) {
return Double.compare(first.area(), second.area());
}
}The comparator is now closed to routine modification. It does not need to know which concrete shapes exist.
At the same time, the application remains open for extension because developers can create additional implementations of Shape.
Add a new shape without changing existing code
For example, a rectangle can be added without changing AreaComparator, Square or Circle:
final class Rectangle implements Shape {
private final double width;
private final double height;
Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
}The existing comparator immediately works with the new class:
var comparator = new AreaComparator();
var square = new Square(5);
var circle = new Circle(3);
var rectangle = new Rectangle(4, 6);
System.out.println(comparator.compare(square, circle));
System.out.println(comparator.compare(rectangle, square));No changes to AreaComparator were required. That is the open-closed principle in action.
Why use Double.compare()?
The original style of subtracting one area from another is not ideal for comparison logic. With floating-point values, the subtraction result is a double, while a comparator conventionally needs a negative integer, zero or a positive integer.
Double.compare() expresses the intent directly:
int result = Double.compare(first.area(), second.area());The result is negative when the first area is smaller, zero when the values are equal and positive when the first area is larger.
A functional Java 15 alternative
Java interfaces do not always need several concrete implementations. Java 15 also supports functional interfaces and lambdas, which can make the same design extremely compact.
@FunctionalInterface
interface Shape {
double area();
}
var comparator = new AreaComparator();
Shape square = () -> 5 * 5;
Shape circle = () -> Math.PI * 3 * 3;
System.out.println(comparator.compare(square, circle));This approach is useful when the abstraction represents a single behavior and a dedicated class would add little value. For richer domain objects, concrete classes such as Square and Circle are usually clearer.
Open-closed does not mean never modify code
The phrase "closed for modification" is sometimes interpreted too literally. The open-closed principle does not mean an existing class can never be edited.
Code still changes when bugs are fixed, requirements change or the abstraction itself evolves. The goal is to design stable components so that every new variation does not require another conditional, overload or source-code modification.
A useful warning sign is code that repeatedly grows like this:
if (shape instanceof Square) {
// Square behavior
} else if (shape instanceof Circle) {
// Circle behavior
} else if (shape instanceof Rectangle) {
// Rectangle behavior
}If every new implementation requires another branch in stable business logic, an interface, strategy or other abstraction may provide a cleaner extension point.
Open-closed principle best practices
- Program against useful abstractions rather than concrete implementations when variation is expected.
- Use interfaces to define capabilities that multiple implementations can provide.
- Prefer composition and polymorphism over growing chains of type checks.
- Do not create extension points for hypothetical requirements that may never exist.
- Keep abstractions small enough that implementations can honor their contracts naturally.
- Refactor toward OCP when repeated changes reveal a genuine axis of variation.
SOLID's open-closed principle in Java
The key to the open-closed principle is identifying what is likely to vary and placing a stable abstraction around it.
In this example, individual shapes vary, but the requirement to calculate and compare their areas remains stable. The Shape interface captures that common behavior, while AreaComparator operates only on the abstraction.
New shapes can therefore be introduced without rewriting the comparison logic. The application is open to new Shape implementations while the established comparator remains closed to routine modification.