Inheritance vs Composition in OOP: Which to Use and Why
ProgrammingLesson 2 of 46 min read
What you'll learn
- Recognise when an inheritance hierarchy starts multiplying classes
- Explain the fragile base class problem with a concrete bug
- Rebuild the same feature set with composition, wrapper objects and a protocol
- Apply a short checklist to decide between "is-a" inheritance and "has-a" composition
On this page
An uptime monitor starts life sending alerts by email. Then someone wants Slack, so you subclass. Then someone wants both at once. Then email should retry on failure, but Slack should not. By the fifth requirement the codebase has EmailAlert, SlackAlert, EmailAndSlackAlert, RetryingEmailAlert and a sinking feeling. This lesson is about why that happens and the alternative.
Why the hierarchy keeps growing
Inheritance lets a class reuse and specialise another: EmailAlert *is an* Alert. It is the first reuse mechanism most people learn, so it is the one they reach for. The trouble is that inheritance fixes a relationship at the moment you write the class. Each independent feature (which channel, whether to retry, whether to throttle) becomes another axis, and a hierarchy can only branch along one axis at a time. Combining features means a new subclass for every combination.
class Alert:
def send(self, message):
raise NotImplementedError
class EmailAlert(Alert):
def send(self, message):
print(f"[email] {message}")
class SlackAlert(Alert):
def send(self, message):
print(f"[slack] {message}")
# New requirement: some alerts go to both channels.
class EmailAndSlackAlert(Alert):
def send(self, message):
print(f"[email] {message}")
print(f"[slack] {message}")
# Next requirement: retry the email once if it fails. Now what?
# RetryingEmailAlert, RetryingSlackAlert, RetryingEmailAndSlackAlert...
# Each new feature multiplies the class count.
EmailAndSlackAlert().send("disk 91% full on db-2")Output
[email] disk 91% full on db-2 [slack] disk 91% full on db-2
The code is fine today. The comment at the bottom is the warning: with three channels and two behaviours you are heading for six or more classes, most of them copy-pasted. The count grows multiplicatively, while the features you actually wrote grow only additively.
The hidden coupling: the fragile base class
There is a quieter problem. A subclass depends on how its parent works, not just on what the parent promises. In the example below, the base class send_many happens to call self.send for each message, which is an implementation detail nobody documented. The subclass author wants to count messages and overrides both methods. The result is a double count.
class Notifier:
def __init__(self):
self.delivered = 0
def send(self, message):
self.delivered += 1
print("sent:", message)
def send_many(self, messages):
for message in messages:
self.send(message) # the base class happens to call self.send
class CountingNotifier(Notifier):
"""Subclass author wants to track how many messages went out."""
def __init__(self):
super().__init__()
self.count = 0
def send(self, message):
self.count += 1
super().send(message)
def send_many(self, messages):
self.count += len(messages) # "helpfully" count the batch too
super().send_many(messages)
n = CountingNotifier()
n.send_many(["cpu high", "disk full", "queue backed up"])
print("messages:", 3, "| counted:", n.count)Output
sent: cpu high sent: disk full sent: queue backed up messages: 3 | counted: 6
Neither class is obviously wrong. CountingNotifier.send_many counts the batch, then the parent loops and calls self.send, which is now the overridden version that counts again. If the maintainer of Notifier later changes send_many to stop calling self.send, the subclass silently under-counts instead. This is the fragile base class problem: changes to a parent can break children that never touched the changed code.
Composition: assemble behaviour from parts
Composition means an object holds references to other objects and delegates work to them: a Monitor *has* channels. Instead of encoding every feature combination as a class, you write each feature once as a small object with the same interface, then combine instances at run time. The wrappers Retrying and Prefixed below accept any channel, so they work with email, Slack or a channel that does not exist yet. The Protocol documents the shape a channel must have without requiring a shared parent class.
from typing import Protocol
class Channel(Protocol):
def send(self, message: str) -> None: ...
class EmailChannel:
def __init__(self):
self.failures_left = 1 # simulate one flaky delivery
def send(self, message):
if self.failures_left:
self.failures_left -= 1
raise ConnectionError("smtp timeout")
print(f"[email] {message}")
class SlackChannel:
def send(self, message):
print(f"[slack] {message}")
class Retrying:
"""Wraps any channel and retries it; knows nothing about email or Slack."""
def __init__(self, inner: Channel, attempts: int = 2):
self.inner = inner
self.attempts = attempts
def send(self, message):
for attempt in range(1, self.attempts + 1):
try:
self.inner.send(message)
return
except ConnectionError as err:
print(f" attempt {attempt} failed: {err}")
raise ConnectionError("gave up")
class Prefixed:
def __init__(self, inner: Channel, prefix: str):
self.inner = inner
self.prefix = prefix
def send(self, message):
self.inner.send(f"{self.prefix} {message}")
class Monitor:
def __init__(self, channels):
self.channels = list(channels) # has-a: a list of collaborators
def alert(self, message):
for channel in self.channels:
channel.send(message)
monitor = Monitor([
Retrying(EmailChannel()),
Prefixed(SlackChannel(), "[PAGE]"),
])
monitor.alert("disk 91% full on db-2")Output
attempt 1 failed: smtp timeout [email] disk 91% full on db-2 [slack] [PAGE] disk 91% full on db-2
Read the output as a story. The email channel fails once, Retrying logs the failed attempt and tries again, and the second try succeeds. The Slack message passes through Prefixed, which adds [PAGE]. To get "retrying Slack with a prefix" you write one line of wiring, not a new class. Testing is easier too: hand Monitor a list of fake channels and no network is involved.
A short decision guide
| Question | If yes, lean towards | Example |
|---|---|---|
| Is it truly an "is-a" relationship and would every parent behaviour make sense on the child? | Inheritance | InsufficientBalance(Exception) |
| Do you need to mix and match features at run time? | Composition | Retry + prefix + channel |
| Are you inheriting only to reuse some code? | Composition | A Stack that wraps a list |
| Do you control the parent and is it stable and documented for extension? | Inheritance is safe | Your own template-method base class |
| Does the framework require a base class? | Inheritance, kept thin | A web framework's view base class |
| Would you need more than two levels of hierarchy? | Composition | Anything with a BaseAbstractManagerImpl |
The most useful test is substitutability: could every place that expects a parent safely receive the child? If a subclass must disable, ignore or throw on inherited behaviour, the "is-a" claim is false. Polymorphism and interfaces, the next lesson, make that precise.
Common mistakes
class Channel:
def __init__(self, name):
self.name = name
class SmsChannel(Channel):
def __init__(self, name, number):
# forgot: super().__init__(name)
self.number = number
def describe(self):
return f"{self.name} -> {self.number}"
try:
print(SmsChannel("on-call", "+91-90000-00000").describe())
except AttributeError as err:
print("AttributeError:", err)Output
AttributeError: 'SmsChannel' object has no attribute 'name'
class Stack(list): # inherits ALL of list just to reuse append and pop
def push(self, item):
self.append(item)
class StrictDict(dict):
def __setitem__(self, key, value):
if not isinstance(key, str):
raise TypeError("keys must be strings")
super().__setitem__(key, value)
s = Stack()
s.push("a")
s.push("b")
s.insert(0, "sneaky") # a stack should never allow this
print(s)
d = StrictDict()
d.update({42: "bypassed"}) # update() does not go through our __setitem__
print(d)Output
['sneaky', 'a', 'b']
{42: 'bypassed'}- Forgetting `super().__init__()`. The subclass never gets the parent's fields, so the first use fails with
AttributeError: 'SmsChannel' object has no attribute 'name', as shown above. - Inheriting to borrow methods.
Stack(list)exposesinsert,sortand everything else, so callers can break the stack discipline, as the first output line shows. Hold a list inside the class instead, and expose onlypushandpop. - Subclassing built-in containers and trusting overrides.
StrictDictvalidates__setitem__, butupdate()bypasses it in CPython and{42: 'bypassed'}gets in. Wrap a dict or subclasscollections.UserDict, which routes through your methods. - Overriding two methods that call each other. The double count in
CountingNotifier. Override the smallest hook, or document which methods are safe to override. - Deep hierarchies. Four or five levels mean a reader must open four or five files to know what one call does. Flatten them with composition.
- Multiple inheritance as a shortcut. Python resolves conflicts with the method resolution order, but a diamond of parents still makes behaviour hard to predict. Prefer small mix-ins with no state, or composition.
- Wrapper that leaks the wrong interface. A decorator class that forgets to forward one method (for example
close) works in tests and fails in production. Keep the shared interface small.
How this shows up in real projects and interviews
In everyday work you inherit from framework classes (a base exception, a view, a test case) and compose almost everything else: a service holds a repository, a client, a logger. Large legacy codebases usually show the opposite pattern, with deep hierarchies that nobody dares touch, and refactoring them means extracting the varying behaviour into small collaborators one at a time. Dependency injection frameworks are, at heart, a way to automate composition.
In interviews, "inheritance versus composition" is a standard design question. A strong answer names the coupling between parent and child, offers a concrete case where inheritance went wrong (the fragile base class is a good one), and admits when inheritance is the right call. For the vocabulary used here, revisit classes and encapsulation, and for named, reusable versions of the wrapper idea see the decorator and strategy patterns in Design Patterns.
Try it yourself
Extend composition.py with a Throttled wrapper that allows at most N messages per run and silently drops the rest while printing a dropped: line. Then build a monitor where Slack is throttled to 2 messages, email is wrapped in Retrying, and send 4 alerts. Finally, write down which extra classes the inheritance version of the same feature set would have needed.
Show a hint
Throttled should look just like Retrying: it takes an inner channel, keeps a counter, and implements send. Because wrappers share the channel interface you can nest them, for example Throttled(Prefixed(SlackChannel(), "[PAGE]"), limit=2).
Key takeaways
- Inheritance fixes one relationship at class-definition time, so independent features multiply into many subclasses.
- A subclass depends on its parent's implementation, which is why changing the parent can silently break children.
- Composition combines small objects that share an interface at run time, which keeps classes few and tests simple.
- Use inheritance for true is-a relationships where every parent behaviour makes sense on the child, and keep hierarchies shallow.
- Do not subclass a type just to borrow some of its methods; wrap it and expose only what you want.
Test yourself
A trick from this lesson's ride: Inheritance multiplies classes, composition adds them. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
What is the difference between inheritance and composition?
Inheritance models an is-a relationship: a subclass reuses and extends a parent class. Composition models a has-a relationship: an object holds other objects and delegates work to them. Composition is more flexible because the parts can be swapped or combined at run time.
Why is composition preferred over inheritance?
Inheritance couples a child to the parent's implementation and fixes the relationship when you write the code, so combining features creates many subclasses and parent changes can break children. Composition keeps parts small and replaceable and makes testing easier, though inheritance is still the right tool for genuine is-a relationships.
What is the fragile base class problem?
It is the risk that a seemingly safe change in a parent class breaks its subclasses because they depend on its internal behaviour, such as which of its methods call each other. It is a main reason to limit inheritance depth and document or avoid overriding methods.