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.
Inheritance is useful when a subtype genuinely satisfies and extends a stable abstraction. It becomes an anti-pattern when it is mainly used to combine independent behavior variations, because each new combination changes the hierarchy instead of composing existing parts.
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 practical rule: 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.
Once independent concerns such as delivery channel, urgency, and logging mode are encoded as inheritance levels, each new combination requires more types or another branch. The hierarchy becomes a representation of combinations rather than a model of stable subtype relationships.
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
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.
Related Articles
Learn why subclasses that ignore or override inherited methods signal a broken IS-A relationship, and how interface segregation resolves the LSP violation cleanly.
Learn why a class that knows too much and does too many unrelated things is hard to test, extend, and maintain, and how to break it apart using Single Responsibility and Extract Class.