Inheritance vs Composition
Inheritance bundles three separate things — subtyping, code reuse and open recursion — and the fragile base class problem comes from the bundling; when composition and delegation are the right default, and the narrow cases where a hierarchy is still correct.
"Favour composition over inheritance" is repeated so often that the reason gets lost, and without the reason it degrades into a style preference. The reason is mechanical: extends bundles three separable capabilities into one keyword, and you almost never want all three.
- Subtyping — instances of the subclass are usable where the superclass is expected.
- Implementation reuse — the subclass inherits the superclass's code and state.
- Open recursion — the superclass's own calls to
this.method()dispatch back into the subclass's overrides.
Interfaces give you (1) alone. Composition gives you (2) alone. Only inheritance gives you (3) — and (3) is where the failures come from, because it means the superclass's internal call graph is part of its contract whether the author documented it or not.
The fragile base class problem #
Bloch's canonical demonstration. A set that counts how many elements were ever offered:
class CountingSet<E> extends HashSet<E> {
private int added = 0;
@Override public boolean add(E e) { added++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
added += c.size();
return super.addAll(c);
}
public int added() { return added; }
}
CountingSet<String> s = new CountingSet<>();
s.addAll(List.of("a", "b", "c"));
s.added(); // 6, not 3The code is correct against the documented interface and wrong against the implementation: HashSet.addAll happens to call this.add for each element, so the override runs twice. Nothing in the API says whether it does. Worse, the answer can change in a future release of the JDK, silently breaking a subclass that was never edited — which is the "fragile" part. The dependency is not on the superclass's contract but on its self-calls.
The same shape produces security bugs: a subclass that overrides a validating method can be bypassed if the base class calls a different internal path, and a base-class constructor that calls an overridable method runs that override before the subclass's fields are initialized.
class Base { Base() { init(); } void init() {} }
class Derived extends Base {
private final String name = "set";
@Override void init() { System.out.println(name); } // prints null
}This is not a Java quirk — C# and C++ have their own versions (in C++ the vtable is the base's during base construction, so the override is not even called). Never call an overridable method from a constructor.
Composition and forwarding #
The same feature, built as a wrapper:
class CountingSet<E> implements Set<E> {
private final Set<E> inner; // has-a, not is-a
private int added = 0;
CountingSet(Set<E> inner) { this.inner = inner; }
@Override public boolean add(E e) { added++; return inner.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
added += c.size(); return inner.addAll(c);
}
// ... remaining Set methods forward to inner
public int added() { return added; }
}inner.addAll cannot call back into the wrapper, so the count is right and stays right across JDK versions. It also composes: this wrapper works over a HashSet, a TreeSet, or a synchronized set, which the subclass never could. That is the decorator shape, and the cost is real — the boilerplate of forwarding every method, unless the language does it for you:
class CountingSet<E>(private val inner: MutableSet<E>) : MutableSet<E> by inner {
var added = 0; private set
override fun add(e: E): Boolean { added++; return inner.add(e) }
}Hierarchies that go wrong in a predictable way #
| Hierarchy | Why it breaks |
|---|---|
Square extends Rectangle |
setWidth/setHeight are independent on a rectangle and cannot be on a square — a Liskov violation. Immutable versions are fine; the mutation is what breaks it |
Stack extends Vector (real, in the JDK) |
a stack exposes get(i), insertElementAt, removeElementAt — the LIFO invariant is unenforceable |
Properties extends Hashtable (real) |
put accepts any Object, so a Properties can hold non-string keys and store() then fails |
Deep domain trees (Employee → Manager → Director) |
roles change at run time and combine; inheritance is fixed at construction and single |
The pattern in all four: inheritance was used for reuse, and dragged in subtyping that the domain does not actually satisfy.
Where inheritance is the right tool #
- Closed, exhaustive hierarchies — a shape is a circle, a rectangle or a triangle, and nothing else. This is an algebraic data type, and modern languages express it directly: Java
sealed(JEP 409, final in Java 17), Kotlinsealed, Rustenum, Swiftenum. The compiler then checks that aswitchcovers every case, which is the actual payoff. - Framework extension points designed for it — the template method pattern, where the base class documents precisely which methods are hooks and what the call order is. Designing for inheritance means writing that documentation; if you cannot, make the class
final. - Within one module, one author — the fragility is a distance problem. A base class and its subclasses in the same package, changed together, are a much smaller risk than a base class published to strangers.