Software Design Reference Core Designer · Structuring for Stability

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.

Inheritance's one genuine job: polymorphism against a stable interface — the caller depends on the base type, implementations vary. If the base type changes often, it isn't stable, and the "is-a" is a trap.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
super().__init__() then new stateThe subclass now depends on the base’s construction order.Hold the base as a field; own your own lifecycle.
a subclass that overrides nothingIt inherits to borrow, not to vary — the fuse with no upside.Delegate: self._base = Base().
isinstance(x, Base) in the callerThe hierarchy exists for the caller’s branching, not polymorphism.Give the base one method the caller can call blind.
an abstract base with one subclassAncestry 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

Unsure whether your hierarchy is legitimate variation? Ask your agent-teacher to interrogate the "is-a" claim with you.