How OCP and LSP Work Together
The Relationship
OCP and LSP are two sides of the same coin:
- OCP is the goal: design systems so that adding new behaviour does not require modifying existing code.
- LSP is the precondition: polymorphism-based extension (the OCP mechanism) only works if subtypes honestly satisfy their supertype’s contract. Without LSP, OCP fails silently at runtime.
You design a system with an abstract type (interface or abstract class) that is closed for modification (OCP). You then extend the system by writing new classes that implement that type. But that extension only works correctly if the new classes truly satisfy the type’s contract — that is LSP. If a subclass violates LSP, the polymorphic code written against the abstract type will misbehave.
The Dependency
OCP depends on LSP. Here is the chain:
- OCP says: write client code against an abstract type
T, not concrete types. - Polmorphism says: at runtime, any subtype of
Tcan be substituted. - But this substitution is only safe if every subtype of
Tbehaves consistently withT’s contract. - LSP defines “behaves consistently” — the subtype must not strengthen preconditions or weaken postconditions.
If step 3 fails, the OCP-compliant client code is silently broken. The system compiles, runs, and produces wrong results.
Concrete Example: Sorting and Comparable
The standard Collections.sort(List<T> list) method works with any Comparable<T>:
public static <T extends Comparable<? super T>> void sort(List<T> list) {
// relies on compareTo implementing a total order:
// - Antisymmetry: sign(a.compareTo(b)) == -sign(b.compareTo(a))
// - Transitivity: if a.compareTo(b) > 0 && b.compareTo(c) > 0, then a.compareTo(c) > 0
// - Consistency with equals: a.compareTo(b) == 0 should imply a.equals(b)
}
The sorting algorithm (OCP-compliant — works with any Comparable implementation) depends on compareTo satisfying the Comparable contract (LSP). If a subclass’s compareTo violates transitivity, the sorting algorithm may crash, produce incorrectly sorted output, or enter an infinite loop.
Consider a deliberately broken subclass:
class BrokenComparable implements Comparable<BrokenComparable> {
private final int value;
BrokenComparable(int value) { this.value = value; }
@Override
public int compareTo(BrokenComparable other) {
if (this.value == other.value) return 0;
// violates antisymmetry: always returns 1 for unequal values
return 1;
}
}
Pass a list of BrokenComparable instances to Collections.sort(). The TimSort algorithm (used by the JDK) contains internal assertions that detect inconsistent comparison results and throws IllegalArgumentException: Comparison method violates its general contract!.
The system compiled. The types were correct. But it failed at runtime because an LSP-violating implementation was plugged into OCP-compliant code.
Another Example: HashSet and hashCode
HashSet uses hashCode() and equals(). Its correctness depends on the contract:
- Equal objects must have equal hash codes.
- Hash codes should be consistent (same object, same hash code as long as it is not mutated).
If a class overrides equals() but not hashCode(), or mutates fields that affect hashCode() after insertion, HashSet operations break:
class MutablePoint {
int x, y;
@Override
public boolean equals(Object o) {
if (!(o instanceof MutablePoint p)) return false;
return x == p.x && y == p.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
}
Set<MutablePoint> set = new HashSet<>();
MutablePoint p = new MutablePoint();
p.x = 1; p.y = 2;
set.add(p);
p.x = 99; // mutation after insertion — breaks hash code consistency
System.out.println(set.contains(p)); // false — can't find it, wrong bucket
The HashSet is OCP-compliant — it works with any Object type. But it depends on the hashCode/equals contract being honoured (LSP). When it is violated, the collection silently produces wrong results.
The JDK equals() LSP Violation
A subtler LSP violation that breaks OCP-compliant collections:
class Point {
final int x, y;
@Override
public boolean equals(Object o) {
if (!(o instanceof Point p)) return false;
return x == p.x && y == p.y;
}
}
class ColouredPoint extends Point {
final String colour;
@Override
public boolean equals(Object o) {
if (!(o instanceof ColouredPoint cp)) return false;
return super.equals(cp) && colour.equals(cp.colour);
}
}
Point p = new Point(1, 2);
ColouredPoint cp = new ColouredPoint(1, 2, "red");
System.out.println(p.equals(cp)); // true — Point.equals uses instanceof, matches
System.out.println(cp.equals(p)); // false — ColouredPoint.equals requires ColouredPoint
Symmetry is broken. A Set<Point> containing both p and cp has undefined behaviour depending on which object’s equals() is called. The OCP-compliant collection is silently corrupted by an LSP-violating subclass.
Fixing the equals() Contract
To maintain LSP with equals() in a hierarchy, use one of these approaches:
- Use
getClass()instead ofinstanceof— objects of different classes are never equal:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
- Favour composition over inheritance —
ColouredPointholds aPointfield rather than extending it. ThenColouredPoint.equals()delegates topoint.equals()for position equality.
The Practical Takeaway
When you design a system:
- Identify the abstraction points (interfaces, abstract classes) that will enable OCP.
- Document the contract of each abstraction — not just the method signatures, but the behavioural expectations (preconditions, postconditions, invariants).
- Every new implementation of an abstraction must be verified against that contract — this is LSP checking.
- If a proposed subclass cannot satisfy the contract, it should not be a subclass. Use composition, a different interface, or a different hierarchy.
OCP without LSP is a trap: code that compiles but breaks silently. LSP without OCP is a design pattern without a use case. Together, they form the foundation of robust, extensible OOP design.