Inheritance over composition anti-pattern
Learn why deep inheritance hierarchies become brittle and hard to change, and how favoring composition with strategy injection leads to more flexible, testable designs.
TL;DR
- Deep inheritance hierarchies are rigid: changing a base class can break subclasses in unexpected ways (the fragile base class problem).
- Inheritance couples a subclass to all current and future implementation details of its parent, not just its interface.
- Class explosion is the math problem: N behaviors x M variations = N*M classes with inheritance, but only N+M components with composition.
- The Gang of Four said it in 1994: "Favor composition over inheritance." Effective Java Item 18 repeats it.
- The rule of thumb: if you cannot say "subclass IS-A superclass" truthfully for the lifetime of the codebase, use composition.
The Problem
Your notification system starts simple: EmailNotification, SlackNotification, SmsNotification. Then product asks for urgent variants. Then logging variants. Then GDPR-compliant variants that must not log PII. You add abstract layers.
// Level 1: Base
public abstract class Notification {
public void send(String message) {
String formatted = format(message);
deliver(formatted);
log(formatted);
}
protected abstract String format(String message);
protected abstract void deliver(String formatted);
protected void log(String message) { db.insert(message); }
}
// Level 2: Category layer
public abstract class AlertNotification extends Notification {
@Override
public void send(String message) {
super.send("[ALERT] " + message);
escalate(message);
}
protected abstract void escalate(String message);
}
// Level 3: Channel layer
public abstract class SlackAlertNotification extends AlertNotification {
@Override
protected void deliver(String msg) { slackClient.post(msg); }
}
// Level 4: Variant layer
public class UrgentSlackAlert extends SlackAlertNotification {
@Override
protected String format(String msg) { return "URGENT: " + msg; }
@Override
protected void escalate(String msg) { pagerDuty.page(msg); }
}
Four levels deep. Now product asks: "Some alerts should not log (GDPR)." Where does the flag go? Adding it to Notification propagates to every subclass. Creating a NoLogNotification layer means duplicating the entire hierarchy underneath it.
I've seen a real codebase where this pattern produced 47 notification classes across a 5-level hierarchy. Adding a single new delivery channel required creating 9 new classes. The team spent more time managing the hierarchy than writing business logic.
The fragile base class problem is the core issue. When Notification.send() changes its template (say, adding a validate() step), every subclass that calls super.send() inherits that change silently. Subclasses that depended on the old order of operations break without any code change in the subclass itself.
Why It Happens
- "IS-A" thinking as the default. Developers reach for
extendsfirst because it maps naturally to how we categorize things in the real world. "A SlackAlert IS-A Alert" sounds correct, but it conflates taxonomy with implementation. - Code reuse via inheritance is seductive. One
extendskeyword gives you all the parent's methods for free. Composition requires explicit delegation, which feels like more work upfront. - IDE scaffolding encourages it. "Generate subclass" is one click. "Extract interface, create delegate, wire constructor" is five steps. The path of least resistance leads to inheritance.
- The problem is invisible at first. A 2-level hierarchy works fine. The pain arrives at level 3 or 4, by which point the hierarchy is load-bearing and expensive to refactor.
The math of class explosion
With inheritance: N notification types x M delivery channels x P logging variants = N * M * P classes. With composition: N formatters + M deliverers + P loggers = N + M + P components. For 3 types, 4 channels, and 2 logging modes: inheritance needs up to 24 classes. Composition needs 9 components.
How to Detect It
| Signal | What It Means | How to Check |
|---|---|---|
| Hierarchy deeper than 2 levels | Fragile base class risk increases with each level | Count extends depth in your class hierarchy |
| New feature requires classes in multiple hierarchy branches | Class explosion in progress | Check if adding a feature means 3+ new classes |
super.method() calls scattered across overrides | Subclasses tightly coupled to parent's implementation order | Search for super. calls in override methods |
| Base class changes break unrelated subclasses | Fragile base class problem is active | Track how often base class changes cause failures in subclasses |
| Subclass only uses 30% of parent's methods | The IS-A relationship is weak (related to refused bequest) | Compare methods used vs. methods inherited |
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with NotesFromSDE Premium.