What you'll learn
- Build objects out of other objects instead of inheriting, and know when each is right
- Apply single responsibility: one class, one reason to change
- Pass collaborators in through the constructor so they can be swapped and tested
- Recognize the handful of design rules that keep object-oriented code readable
Concept
Inheritance answers "is a". Composition answers "has a". A car has an engine; it is not a kind of engine. A report has a formatter; it is not a kind of formatter. Composition means an object holds other objects as attributes and delegates work to them. It is the more common relationship in real code by a wide margin, and the rule "prefer composition over inheritance" exists because inheritance is the tool people reach for first and regret most.

Why prefer it? Because parts can be swapped. A Report that inherits from CsvFormatter is a CSV report forever; a Report that holds a formatter can be given a Markdown one, or a fake one in a test, by passing a different object to its constructor. This is dependency injection, and it sounds grander than it is: the object receives what it depends on rather than creating it. The result is a class that can be tested without a database, a network, or a filesystem, because those collaborators are handed in.

Single responsibility is the rule that decides where to draw class boundaries: a class should have one reason to change. If Report fetches data, formats it, and writes files, then a change to any of those three forces a change to Report, and the class grows until nobody understands it. Split along the reasons: a source, a formatter, a writer, and a small Report that coordinates them. Each piece is short, each is testable, and each can be replaced.
| Smell | Rule it breaks | Fix |
|---|---|---|
| a class over ~200 lines | single responsibility | split along the reasons it changes |
Class(OtherClass) only to reuse a method |
prefer composition | hold an instance and delegate |
| a class creates its own database/HTTP client | dependency injection | receive it in __init__ |
an isinstance chain |
polymorphism | a method with one name on each class |
| a hierarchy four levels deep | prefer composition | flatten; compose behaviors |
Delegation is the mechanical heart of composition: a method on the outer object calls a method on an inner one. report.render() calls self.formatter.format(rows). Sometimes the outer class exposes several inner methods one by one; if it exposes all of them, ask whether the outer class is needed at all. Keep the interface of the composite small and meaningful: callers should talk to Report, not reach into report.formatter.
These rules are the practical residue of the four pillars. Encapsulation keeps each part's state private. Abstraction keeps each interface small. Inheritance is reserved for true "is a" families, ideally one level deep under an ABC. Polymorphism lets the composite hold any part that speaks the interface. When code follows them, adding a feature means adding a class, not editing five.
Code
Inheritance versus composition for the same need. The inherited version is stuck with one formatter; the composed version accepts any object with a format method.
# compose.py
class CsvFormatter:
def format(self, rows):
return "\n".join(",".join(map(str, r)) for r in rows)
class MarkdownFormatter:
def format(self, rows):
return "\n".join("| " + " | ".join(map(str, r)) + " |" for r in rows)
class InheritedReport(CsvFormatter): # "is a" CSV formatter: stuck with it
def __init__(self, rows):
self.rows = rows
def render(self):
return self.format(self.rows)
class Report: # "has a" formatter: swappable
def __init__(self, rows, formatter):
self.rows = rows
self.formatter = formatter
def render(self):
return self.formatter.format(self.rows)
rows = [("name", "score"), ("ada", 91)]
print(InheritedReport(rows).render())
print(Report(rows, CsvFormatter()).render())
print(Report(rows, MarkdownFormatter()).render())
name,score
ada,91
name,score
ada,91
| name | score |
| ada | 91 |Single responsibility in practice: a source, a formatter, a writer, and a coordinator. Each class is a few lines, and each can change without touching the others.
# responsibilities.py
from pathlib import Path
class ListSource:
def __init__(self, rows):
self._rows = rows
def rows(self):
return list(self._rows)
class CsvFormatter:
def format(self, rows):
return "\n".join(",".join(map(str, r)) for r in rows)
class FileWriter:
def __init__(self, folder):
self.folder = Path(folder)
self.folder.mkdir(exist_ok=True)
def write(self, name, text):
path = self.folder / name
path.write_text(text, encoding="utf-8")
return path
class Report:
def __init__(self, source, formatter, writer):
self.source, self.formatter, self.writer = source, formatter, writer
def publish(self, name):
return self.writer.write(name, self.formatter.format(self.source.rows()))
report = Report(ListSource([("ada", 91), ("linus", 67)]), CsvFormatter(), FileWriter("out"))
path = report.publish("scores.csv")
print(path)
print(path.read_text(encoding="utf-8"))
out/scores.csv
ada,91
linus,67Dependency injection paying off: the same Report runs against a fake writer that keeps text in memory, so a test needs no filesystem.
# inject.py
class CsvFormatter:
def format(self, rows):
return "\n".join(",".join(map(str, r)) for r in rows)
class ListSource:
def __init__(self, rows):
self._rows = rows
def rows(self):
return list(self._rows)
class MemoryWriter:
def __init__(self):
self.files = {}
def write(self, name, text):
self.files[name] = text
return name
class Report:
def __init__(self, source, formatter, writer):
self.source, self.formatter, self.writer = source, formatter, writer
def publish(self, name):
return self.writer.write(name, self.formatter.format(self.source.rows()))
writer = MemoryWriter()
Report(ListSource([("ada", 91)]), CsvFormatter(), writer).publish("test.csv")
print(writer.files)
print("ada,91" in writer.files["test.csv"])
{'test.csv': 'ada,91'}
TrueDelegation with a small public surface. Playlist holds a list but exposes only the operations that make sense for a playlist, not the list's forty methods.
# delegate.py
class Playlist:
def __init__(self, name):
self.name = name
self._songs = []
def add(self, song):
self._songs.append(song)
return self
def __len__(self):
return len(self._songs)
def __iter__(self):
return iter(self._songs)
def __repr__(self):
return f"Playlist({self.name!r}, {len(self)} songs)"
p = Playlist("focus").add("intro").add("deep work").add("outro")
print(p)
print(list(p))
print(hasattr(p, "append"), hasattr(p, "add"))
Playlist('focus', 3 songs)
['intro', 'deep work', 'outro']
False TrueTry it yourself
Create notifier.py with a Notifier class that takes a channel object in its constructor and has one method, send(user, message), which formats the message with the user's name and delegates to channel.deliver(text). Write ConsoleChannel that prints, and MemoryChannel that stores messages in a list. Run the same Notifier with both, then add a throttle collaborator that refuses more than two messages per user, injected the same way.
Common mistakes
- Inheriting to reuse. If the sentence "a Report is a CsvFormatter" sounds wrong, it is wrong; hold the formatter instead.
- Creating collaborators inside the class.
self.db = Database(url)in__init__welds the class to a real database; receive the object as a parameter. - Exposing the inner object.
report.formatter.format(...)from outside breaks encapsulation; giveReportthe method callers need.
What's next
- Composition ("has a") is the default relationship; inheritance ("is a") is the exception, kept shallow and ideally under an ABC.
- One class, one reason to change; split along responsibilities, and inject collaborators through the constructor so they can be swapped and faked.
- Delegate through a small public surface, and let new features arrive as new classes rather than edits to old ones.
The OOP module is complete: you can model, protect, extend, and compose objects. Next you return to data, with the standard library's tools for dates and times.