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.
Composition is preferred not because it is prettier but because inheritance couples you to the base class's implementation — not merely its interface — through three channels that are invisible from the subclass's source.
Construction order. Subclass state and super().__init__() interleave, so the base can call a method the subclass has overridden before the subclass's own fields exist. The bug appears as an AttributeError in code that looks correct.
The fragile base class problem. A base method is free to change which of its own methods it calls. If it starts implementing one operation in terms of another that a subclass overrode, every subclass's behaviour changes silently. Nothing in the subclass's file changed; nothing in the base's diff mentions the subclass.
A shared namespace. Base and subclass write to one set of attribute names. Adding a field to the base can collide with one a subclass has been using for years, in a package you do not own, discovered at runtime.
Watch the second one in Python, where you can see the whole mechanism in nine lines:
class CountingList(list):
def __init__(self) -> None:
super().__init__()
self.added = 0
def append(self, item) -> None:
self.added += 1
super().append(item)
def extend(self, items) -> None:
self.added += len(items)
super().extend(items)
This counts correctly — and only because CPython's list.extend does not internally call self.append. If it did, every extended item would be counted twice: once by the override and once by the base re-entering it. The subclass's correctness rests on an implementation detail of the base that no documentation promises and no test of yours can defend. That is the whole argument against reuse-by-inheritance, in a language where you can actually watch it happen.
There is also an asymmetry in time. Composition can always be introduced later: wrap the object, delegate the method, nothing outside changes. Inheritance is expensive to remove once callers depend on the subtype relation, because deleting it changes the type of every variable that held one — including in code you do not own. And inheritance makes a behavioural promise (anything true of the base is true of the subclass, which is Liskov substitutability). Reuse-inheritance makes that promise by accident and then breaks it, and the breakage surfaces in callers that never mention the subclass by name.
Where it stops applying. When there is no implementation to borrow — an abstract base class or a Protocol with no state and no method bodies — the three channels do not exist, and inheritance is simply a declaration of intent. When a framework requires it (a Django model, a unittest.TestCase, an Exception), fighting it costs more than it saves and buys nothing. And when the hierarchy is closed and you own all of it — a fixed set of variants modelled as a base and four dataclasses — the fragile-base problem is bounded by a set you can read in one sitting.
How a senior designer reviews a new subclass
A pull request adds RetryingHttpClient(HttpClient).
They look at two things before the logic: what the subclass overrides, and what state it adds. Those two answers classify it. If it overrides methods the base declares and adds no state, it varies — legitimate. If it overrides nothing and calls super() everywhere while adding fields of its own, it borrows — the fuse.
Then the question: if I deleted every method this class overrides, would anything about the base's contract change for its callers? For a varying subclass the answer is yes — the whole point was to change behaviour behind a shared type. For a borrowing subclass the answer is no, and you have just discovered that the inheritance is doing no work except giving you access to the base's methods, which delegation would give you with a visible dependency instead of a hidden one.
Two alternatives rejected. The first is to keep the hierarchy but tighten the base — make its helpers private, document which methods may be overridden. This does not help, because it is precisely the base's public surface that the borrowing subclass wants; you have added rules without removing the coupling. The second is a mixin. Mixins look like composition — small, focused, combinable — and are multiple inheritance: same shared namespace, same fragile base problem, plus method resolution order, so the behaviour now depends on the order the classes are listed in. A mixin is the fuse with a friendlier name.
The consequence over time, at scale. The end state of reuse-by-inheritance is not a bug; it is paralysis. On one platform team, a base class accumulated forty subclasses and a dozen protected helpers. A change to one helper's contract meant reading forty subclasses to find out who depended on the old behaviour, so the team did it once, took two weeks, shipped one regression — and thereafter refused to touch the base at all. The hierarchy stopped being a design and became a liability nobody was allowed to edit: new behaviour got bolted on outside it, and the "shared" code stopped being shared in any useful sense. That is the real cost, and it does not appear in any one commit.
| 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. |
When this is the wrong move
- Framework base classes. If the ORM, the test runner, or the web framework requires a subclass, inherit and move on. The coupling is real, but the alternative is fighting a tool that will win.
- Exception hierarchies.
class PaymentDeclined(PaymentError)is inheritance used exactly right: the "is-a" is the entire feature, because callers catch by type, and there is no implementation being borrowed. - Closed sum types. A base plus four dataclasses representing four known variants gives you exhaustive handling and a shared type. The set is closed and small, so the fragile-base risk is bounded.
- Wide interfaces. If delegating means forwarding twenty methods to add one behaviour, the wrapper is worse than the fuse. That is a real signal — the interface is too wide — but if you cannot narrow it today, inheriting and writing down why is the honest call. Do not reach for
__getattr__forwarding to make the boilerplate go away: it recreates the fuse invisibly and defeats every tool that reads the class.
- Composition over inheritance means never inherit.
- It is a default, not a prohibition. The maxim is about class inheritance for reuse. Abstract base classes, protocols, exception hierarchies, and framework-mandated bases are all fine, and refusing them produces worse code, not purer code.
- Inheritance is safe if the subclass overrides everything.
- Then the base is giving you nothing but a name — no shared implementation, no shared state. That is what a
Protocolor an abstract base is for, and using a concrete class instead means you inherited the implementation's constraints for free and its benefits not at all. - Mixins are a form of composition.
- They are multiple inheritance. They share the namespace, they can be broken by a change in the base, and their behaviour depends on the method resolution order, so adding a mixin can silently change what an existing one does. The visual resemblance to composition is exactly what makes them dangerous.
- Delegation is boilerplate, so it is worse.
- The boilerplate is the dependency made visible. A wrapper with five forwarding methods tells you, precisely, the five things it needs from its collaborator — which is a smaller and more reviewable claim than "everything my base class has, now and in future versions".
Skill: fuse or seam?
Which situation is inheritance for?
"I need logging, so I'll extend Logger" is:
CountingList counts correctly only because CPython's list.extend:
Practice on your own code
Find a subclass in your codebase (or an AI-generated one) and run the classification test before touching anything: list what it overrides and what state it adds, then ask whether deleting its overrides would change the base's contract for any caller. If it borrows, convert it to composition — hold the base, delegate the method — and notice how much smaller the dependency becomes once it is written out explicitly.
In the lab: the returns-resolution service has a small hierarchy where one subclass exists to borrow rather than to vary. The phase-1 checkpoint asks you to identify which one, convert it to delegation, and show that the resulting class can be exercised in a test without constructing its former base at all.
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
- Explain to a junior, in two sentences, why a subclass can break when nothing in its own file changed.
- Find one hierarchy in your own code this week and name its cost: how many classes you would have to read before you could safely change one base method.
- Predict what happens in a year if the base keeps growing — who ends up refusing to edit it, and where the next piece of shared behaviour gets put instead.
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 — "Inheritance: For Better or for Worse" (ch. 14 in the 2nd edition, ch. 12 in the 1st): subclassing built-in types, why
CountingList-style overrides are unreliable, and when a nominal hierarchy is still the right tool. - Barbara Liskov & Jeannette Wing, "A Behavioral Notion of Subtyping" (ACM TOPLAS, 1994) — the primary source for the promise inheritance makes on your behalf, and therefore for what it means to break it.
- 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.