TL;DR: Inheritance models what something is. Composition models what something does. The moment your objects need to mix behaviors, inheritance turns into a mess of deep hierarchies and copy-pasted code. Composition keeps behaviors small, swappable, and independently testable.
Inheritance looks elegant on a whiteboard. A Dog extends Animal. A Manager extends Employee. Clean, obvious, done.
Then requirements change. Now you need a RemoteManager. Does it extend Manager? RemoteEmployee? Both? You don't have both, so you make both, and suddenly you're maintaining a tree when you needed a graph.
That's the problem composition solves.
What goes wrong with inheritance
Take a notification system. You start simple:
class Notifier: def send(self, message): ... class EmailNotifier(Notifier): def send(self, message): # send email class SMSNotifier(Notifier): def send(self, message): # send SMS
Product asks for logging. You add it:
class LoggedEmailNotifier(EmailNotifier): def send(self, message): log(message) super().send(message) class LoggedSMSNotifier(SMSNotifier): def send(self, message): log(message) super().send(message)
Now they want retry logic. And rate limiting. And a push notification channel.
Every new behavior multiplies the number of classes. Every new channel means re-implementing every behavior combination. You're not modeling a system anymore — you're managing a taxonomy.
This is the combinatorial explosion problem. n behaviors × m channels = n × m classes, all of them carrying duplicated logic.
Composition: behaviors as objects
Instead of baking behaviors into the hierarchy, make them their own things:
class Notifier: def __init__(self, channel, middlewares=()): self.channel = channel self.middlewares = list(middlewares) def send(self, message): for mw in self.middlewares: message = mw.process(message) self.channel.send(message) class EmailChannel: def send(self, message): ... class SMSChannel: def send(self, message): ... class Logger: def process(self, message): print(f"[log] {message}") return message class RateLimiter: def __init__(self, limit): self.limit = limit self.count = 0 def process(self, message): self.count += 1 if self.count > self.limit: raise RuntimeError("rate limit exceeded") return message
Now you assemble what you need at the call site:
notifier = Notifier( channel=EmailChannel(), middlewares=[Logger(), RateLimiter(limit=100)], ) notifier.send("Your order shipped.")
Adding push notifications? Add PushChannel. Adding retry logic? Add a Retrier middleware. Nothing else changes. n behaviors + m channels = n + m classes, not n × m.
The real difference
| Inheritance | Composition | |
|---|---|---|
| Adding a behavior | Subclass every affected class | Write one new class |
| Swapping a behavior at runtime | Can't | Just swap the object |
| Testing a behavior in isolation | Requires instantiating the parent | Test the behavior class alone |
| Code reuse | Via super() chains |
Via direct delegation |
Inheritance couples what you are to what you can do. Composition separates them.
When inheritance is actually fine
Inheritance isn't wrong — it's just misused.
Use it when the subclass genuinely is a more specific version of the parent and you want to share interface guarantees. Python's Exception hierarchy is a good example: ValueError is an Exception, not something that has exception behavior. The is-a relationship holds cleanly, and you're not mixing orthogonal concerns.
Rule of thumb: If you're inheriting to reuse behavior, reach for composition. If you're inheriting to enforce an interface, inheritance is fine.
Abstract base classes fall into the second category — they define a contract, not an implementation. Using ABC to say "every Channel must implement send()" is inheritance done right.
Key takeaways
- Inheritance models identity; composition models capability. When you need to mix capabilities, inheritance creates an exponential class explosion.
- Behaviors as objects means you can add, remove, and reorder them without touching existing code.
- Composition enables runtime flexibility — you can swap a logger for a no-op logger in tests without subclassing anything.
- The test signal: if you can't test a behavior without instantiating its parent class, it's probably in the wrong place.
- Inheritance still has a job: enforcing interfaces and modeling true
is-arelationships. Don't throw it out — just stop using it as a code-sharing mechanism.