Composition vs Inheritance: When to Use Which
Advanced4 minInnovialabs editorial team
In short
Prefer composition by default: build objects from small, replaceable parts. Use inheritance only for a true, stable "is-a" relationship with a shallow hierarchy.
The short answer
Composition means an object has other objects and delegates work to them. Inheritance means a class is a specialized version of another class and reuses its code.
The common rule "favor composition over inheritance" exists for a reason: inheritance couples a subclass to the internal behavior of its parent, while composition couples objects only through small interfaces. Inheritance is still a good tool, but for a narrower set of cases than most codebases use it for.
The fragile base class problem
A subclass depends not only on the public API of its parent, but often on how the parent implements it. Change the parent, and subclasses break without any change in their own code.
class Collection:
def __init__(self):
self.items = []
def add(self, item):
self.items.append(item)
def add_all(self, items):
for item in items:
self.add(item)
class CountingCollection(Collection):
def __init__(self):
super().__init__()
self.count = 0
def add(self, item):
self.count += 1
super().add(item)
def add_all(self, items):
self.count += len(items)
super().add_all(items)
add_all in the parent calls add, which is overridden, so every item is counted twice. Worse: if the parent author later changes add_all to append directly, the bug disappears — or a different bug appears. The subclass is coupled to an implementation detail it cannot see.
Deep hierarchies and the combination explosion
The second typical problem is a hierarchy that tries to model several independent dimensions:
Notification→EmailNotification,SmsNotification- then
UrgentEmailNotification,UrgentSmsNotification - then
ScheduledUrgentEmailNotification...
Every new dimension (channel, priority, schedule) multiplies the number of classes. Behavior is spread across five levels, and to understand one method you read the whole chain of super() calls.
Refactoring to composition
Split each dimension into a separate component and assemble them:
interface Channel {
send(to: string, text: string): Promise<void>;
}
interface Formatter {
format(text: string): string;
}
class EmailChannel implements Channel {
async send(to: string, text: string) { /* SMTP call */ }
}
class UrgentFormatter implements Formatter {
format(text: string) { return `[URGENT] ${text}`; }
}
class Notifier {
constructor(private channel: Channel, private formatter: Formatter) {}
notify(to: string, text: string) {
return this.channel.send(to, this.formatter.format(text));
}
}
const notifier = new Notifier(new EmailChannel(), new UrgentFormatter());
What you gain:
- N + M classes instead of N × M: adding a channel does not require new priority variants.
- Replaceable at runtime: switch the channel from config.
- Easy testing: pass a fake
Channelinstead of mocking a parent class. - Clear contracts:
Notifierdepends only on two small interfaces.
The counting collection from the previous section is fixed the same way: wrap a collection and delegate to it instead of extending it. Then your add_all does not depend on how the inner collection implements its own methods.
Step-by-step refactoring plan
- Find the varying dimensions in the hierarchy (channel, storage, format, policy).
- Extract an interface for each dimension with the minimum methods needed.
- Move behavior from subclasses into small implementations of those interfaces.
- Inject the parts through the constructor of one class.
- Delete empty subclasses that only combined parts.
- Cover with tests before and after, so behavior stays the same.
When inheritance is still the right tool
| Situation | Inheritance fits? |
|---|---|
| True "is-a" relationship that will not change | Yes |
| Framework requires extending a base class (UI components, exceptions, test cases) | Yes |
| Template method: parent defines the algorithm, child fills one or two steps | Often yes |
| Reusing a few helper methods | No — use composition or functions |
| Several independent variations | No — use composition |
| Parent class is from a third-party library and not designed for extension | No — wrap it |
Rules of thumb:
- Keep hierarchies shallow — one or two levels is usually enough.
- Design for inheritance or forbid it: mark classes
final(Java, Kotlin by default,sealedin C#) unless they are documented for extension. - Do not override methods that the parent calls internally unless the parent documents that this is allowed.
- Liskov substitution check: a subclass must work anywhere the parent works. If it throws "not supported" in an inherited method, the hierarchy is wrong.
Common mistakes
- Using inheritance only to reuse code, not to express a type relationship.
- Creating
BaseService,BaseController,BaseManagerthat collect unrelated helpers. - Going to the other extreme: wrapping everything in layers of delegation where a simple subclass would be clearer.
- Exposing internal state as
protectedfields that subclasses mutate directly.