Lesson 0007 · Core Designer · Module 2
Composition over Inheritance
Inheritance is a fuse: the subclass detonates with the superclass. Composition is a seam. Learn to tell the two apart — and when the fuse is worth keeping.
Mission tie-in: "replace inheritance with composition where it matters" is one of Arjan's seven principles, and the #1 structural fix AI-generated hierarchies usually need.
Knowledge: the fuse and the seam
When FancyLogger extends Logger, every change to Logger — its constructor, its storage, its naming — propagates into FancyLogger. The subclass cannot survive alone, cannot be tested alone, cannot be reused elsewhere. That's the fuse.
The classic failure is subclassing for reuse rather than for polymorphism:
class FileBackedLogger(Logger):
def __init__(self, path: str) -> None:
super().__init__()
self.path = path
self._file = open(path, "a")
def log(self, msg: str) -> None:
super().log(f"[file] {msg}")
self._file.write(f"{msg}\n")
"I need logging, I'll extend the logger" — that's reuse-by-inheritance, and it pays for the fuse: the file-handling lifecycle is now welded to the logger's, and the subclass can't be used with a different base behavior.
Composition says: hold it, delegate to it, decide its life yourself.
class FileBackedLogger:
def __init__(self, sink: Logger, path: str) -> None:
self._sink = sink
self._file = open(path, "a")
def log(self, msg: str) -> None:
self._sink.log(msg)
self._file.write(f"{msg}\n")
Now FileBackedLogger owns its lifecycle, can wrap any logger, and can be tested with a fake sink. The seam is visible: you can see exactly where one behavior ends and the other begins.
| You see this | What it costs | The move |
|---|---|---|
| super().__init__() then new state | The subclass now depends on the base’s construction order. | Hold the base as a field; own your own lifecycle. |
| a subclass that overrides nothing | It inherits to borrow, not to vary — the fuse with no upside. | Delegate: self._base = Base(). |
| isinstance(x, Base) in the caller | The hierarchy exists for the caller’s branching, not polymorphism. | Give the base one method the caller can call blind. |
| an abstract base with one subclass | Ancestry with nothing to vary, paid for on every read. | Collapse to one class; add the base when case two lands. |
Skill: fuse or seam?
Which situation is inheritance for?
"I need logging, so I'll extend Logger" is:
Composition beats inheritance here because the wrapped logger:
Practice on your own code
Find a subclass in your codebase (or an AI-generated one). Ask: does it exist to vary the base, or to borrow from it? If it borrows, convert it to composition — hold the base, delegate the method.
Reveal: a worked conversion
# a CsvExporter that "is a" DatabaseExporter, reusing its row logic
class CsvExporter(DatabaseExporter): # wrong: borrows, doesn't vary
...
# becomes: CsvExporter holds the database reader and formats its rows
class CsvExporter:
def __init__(self, source: RowSource) -> None: # RowSource = any row provider
self._source = source
Your win
You can now classify every subclass you meet: variation (legitimate) or borrowing (fuse). And you have the mechanical fix — delegation — for the second kind.
Read and watch deeper
- Design Patterns, Gamma et al. — the introduction's "Favor object composition over class inheritance": the original statement.
- Head First Design Patterns, Freeman et al. — ch. 1: the duck example that made this principle famous.
- Fluent Python, Ramalho — ch. 14 (ABCs and inheritance): when nominal hierarchy is still the right tool.
- Watch: ArjanCodes YouTube — search "composition over inheritance".
- Reference: Design Principles — composition over inheritance.
Unsure whether your hierarchy is legitimate variation? Ask your agent-teacher to interrogate the "is-a" claim with you.