Draft / Scheduled Content
This article is a draft or scheduled for future publication. The content is subject to change.
Inheritance is a Relic: Composition Over Class Hierarchies
The School of Class Hierarchies
If you learned Object-Oriented Programming (OOP) in school or university, you were likely taught the classic taxidermy of classes:
“An Animal is a base class. A Dog is a subclass of Animal. A GoldenRetriever is a subclass of Dog. A Dog inherits the breathe() method from Animal and overrides the makeSound() method to bark. This is polymorphism, and it is how we reuse code.”
It felt neat, logical, and scientific. We spent hours designing elaborate class hierarchies, drawing arrows pointing up to base classes.
But in the real world of commercial software development, class hierarchies are a trap.
Deep inheritance hierarchies are one of the primary sources of code rigidity, tight coupling, and bugs in OOP systems. They force you to make categorizations about your domain model early in the project, when you have the least amount of information.
Inheritance is a relic of early OOP theory. Modern software design has moved on. It’s time to choose Composition over Inheritance.
The Fragile Base Class Problem
The fundamental flaw of inheritance is that it violates encapsulation.
A subclass is not just using the base class; it is tightly coupled to the internal implementation details of the base class. If you modify the base class, you risk breaking all subclasses in unexpected ways. This is known as the Fragile Base Class problem.
Consider this Java example:
// Base class
class CustomList {
private int addCount = 0;
public void add(Object obj) {
addCount++;
// Add element to list logic...
}
public void addAll(Collection objs) {
for (Object obj : objs) {
add(obj); // Calls add() method!
}
}
public int getAddCount() {
return addCount;
}
}
Now, a developer inherits from this class to create a LoggedList that overrides add and addAll to log operations:
class LoggedList extends CustomList {
@Override
public void add(Object obj) {
System.out.println("Adding: " + obj);
super.add(obj);
}
@Override
public void addAll(Collection objs) {
System.out.println("Adding multiple items...");
super.addAll(objs); // Calls CustomList.addAll()
}
}
If we call loggedList.addAll(List.of("A", "B")), what is the addCount?
You might think it is 2.
But it is actually 4. Why? Because CustomList.addAll() loops through the collection and calls add(). Since LoggedList overrides add(), the call is dispatched back to LoggedList.add(), which calls super.add(), incrementing addCount a second time for each element.
The subclass developer had to know the exact internal implementation of CustomList.addAll() (that it calls add() internally) to write their subclass correctly. If a future maintainer changes CustomList.addAll() to insert elements directly without calling add(), the subclass behavior breaks silently.
The boundary between the classes has dissolved. Encapsulation is dead.
The Taxonomy Trap (The Gorilla-Banana Problem)
Inheritance forces you to model your domain as an is-a relationship: a Dog is-a Animal.
But real-world requirements rarely fit into neat taxonomies. They represent a fluid network of capabilities (has-a or can-do relationships).
Joe Armstrong, the creator of Erlang, famously described the gorilla-banana problem:
“The problem with object-oriented languages is they’ve got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.”
Suppose you have a base class Worker and a subclass Programmer. Later, you create another subclass Manager.
+-----------------------+
| Worker |
+-----------------------+
/ \
v v
+------------+ +-----------+
| Programmer | | Manager |
+------------+ +-----------+
What happens when a developer is promoted to a tech lead, who does both coding (Programmer) and team management (Manager)?
In a single-inheritance language (like Java, C#, or Go), you cannot inherit from both. You are blocked. You have to:
- Duplicate the code of
ManagerinsideProgrammerTechLead. - Refactor the hierarchy, creating a master class
ProgrammerManagerthat contains all methods. - Abandon inheritance and rewrite the classes.
By trying to categorize your entities into rigid boxes, you have made the codebase resistant to change.
The Solution: Composition and Interfaces
How do we reuse code and achieve polymorphism without inheritance? By using Composition.
Instead of inheriting behavior, a class contains instances of other classes that provide the behavior.
Let’s redesign our Tech Lead scenario using composition:
// Define small, single-responsibility components
class Coder {
writeCode() { console.log("Writing code..."); }
}
class Organizer {
manageTeam() { console.log("Planning sprints..."); }
}
// Composition: TechLead contains both components
class TechLead {
private coder = new Coder();
private organizer = new Organizer();
work() {
this.coder.writeCode();
this.organizer.manageTeam();
}
}
This design is incredibly flexible:
- No Fragile Base Class:
TechLeadusesCoderandOrganizervia their public interfaces. It has no access to their internal implementation. - No Taxonomy Trap: If you need a new role—like a
ProductManagerwho can manage teams but doesn’t write code—you just compose a class withOrganizerand a newMarketResearchercomponent. - Easy to Test: You can easily mock or swap the composed components during testing.
+---------------------------------------------+
| TechLead |
| [Contains Coder] ---> writeCode() |
| [Contains Organizer] ---> manageTeam() |
+---------------------------------------------+
Go got it Right
It is telling that Go, a language designed by Google to build massive, maintainable software systems, has no class inheritance.
Go has structs and interfaces. You reuse code by embedding structs inside other structs (composition), and you achieve polymorphism through implicit interfaces.
type Writer interface {
Write([]byte) (int, error)
}
Go forced developers to use composition from day one. And the result is Go codebases that are generally easier to read, refactor, and maintain than equivalent deep Java or C++ hierarchies.
Stop Inheriting
The next time you write extends or class Sub extends Base, stop.
Ask yourself: “Am I using inheritance just to copy a few helper methods?”
If yes, extract those helper methods into a utility class or a separate component and inject it. Save inheritance for the rare cases where you are building a highly specialized framework extension. For your business logic, composition is the only way to build systems that survive contact with the real world.
Related Content
Why Clean Architecture is a Maintainability Nightmare
Robert C. Martin's 'Clean Architecture' promises to decouple your business logic from external frameworks and databases. In practice, it leads to a sprawling wasteland of boilerplate, interface layers that pass data straight through, and excessive mapping functions. Here's why YAGNI should trump architecture dogmatism.
Why ORMs are the Worst Anti-Pattern in Modern Backend Development
Object-Relational Mapping (ORM) tools promise to free developers from SQL. In exchange, they introduce the N+1 query problem, hide database performance realities behind black-box abstractions, and create models that couple application state to database schemas. It's time to write SQL again.
The Fallacy of DRY: Why You Should Write Duplicated Code First
Don't Repeat Yourself (DRY) is one of the first design principles programmers learn. But applying it too early creates tightly coupled, hyper-flexible abstractions that crumble under the weight of changing requirements. Write duplicated code until the structure reveals itself.