Composition Over Inheritance
Deep class hierarchies tend to start simple and become difficult to extend as requirements grow. Composing behavior from small, focused objects — rather than extending it through parent classes — is an approach that avoids the structural problems that deep inheritance creates.
The two subgraphs below contrast the inheritance explosion with the composed design.
The problem with deep hierarchies
Suppose you are building a reporting system. Reports can be exported in different formats, so you
model that as inheritance: a base Report class and two subclasses, PdfReport and CsvReport.
Now a new requirement arrives: some reports are encrypted. You add EncryptedPdfReport and
EncryptedCsvReport. Then a third format appears — Excel — and soon you also need an encrypted
Excel variant. Six classes for three formats and one toggle. Add a compression option and you have
twelve classes for three formats, one encryption toggle, and one compression toggle.
This is the combinatorial explosion: every new dimension of variation multiplies the class count instead of adding one. The hierarchy becomes brittle because a change to the base class ripples down unpredictably, and adding a new export format requires touching six files instead of one.
1# Bad: inheritance hierarchy explodes with each new format or feature.
2class Report:
3 def export(self) -> bytes: ...
4
5class PdfReport(Report):
6 def export(self) -> bytes: ...
7
8class CsvReport(Report):
9 def export(self) -> bytes: ...
10
11class EncryptedPdfReport(PdfReport):
12 def export(self) -> bytes:
13 data = super().export()
14 return self._encrypt(data)
15
16class EncryptedCsvReport(CsvReport):
17 def export(self) -> bytes:
18 data = super().export()
19 return self._encrypt(data)
20
21# Adding ExcelReport now needs EncryptedExcelReport too — 6 classes
22# for 3 formats × 2 encryption states, and no ceiling in sight.
The composed design
The inheritance hierarchy explodes because every new axis of variation multiplies the class count. Three formats times two encryption states already yields six classes — and the count keeps growing.
Instead of subclassing, pull out the varying behavior into its own interface and hold a reference
to it. The Report class has an Exporter — it delegates the format-specific work to whichever
exporter you hand it at construction time. Encryption becomes a decorator that wraps any exporter.
Adding a new format is one new class. Adding encryption requires zero new classes.
A has-a relationship is appropriate when adding a subclass would account for a new variation rather than a new kind of thing. Inheritance models identity; composition models capability.
1from abc import ABC, abstractmethod
2
3class Exporter(ABC):
4 @abstractmethod
5 def export(self, content: str) -> bytes: ...
6
7class PdfExporter(Exporter):
8 def export(self, content: str) -> bytes:
9 return content.encode() # real PDF serialization here
10
11class CsvExporter(Exporter):
12 def export(self, content: str) -> bytes:
13 return content.encode() # real CSV serialization here
14
15class EncryptingExporter(Exporter):
16 """Decorator: wraps any exporter and adds encryption."""
17 def __init__(self, inner: Exporter, key: bytes):
18 self._inner = inner
19 self._key = key
20
21 def export(self, content: str) -> bytes:
22 raw = self._inner.export(content)
23 return self._encrypt(raw)
24
25 def _encrypt(self, data: bytes) -> bytes:
26 return data # real encryption here
27
28# Good: Report holds an Exporter; the format is a runtime decision.
29class Report:
30 def __init__(self, title: str, exporter: Exporter):
31 self._title = title
32 self._exporter = exporter
33
34 def generate(self) -> bytes:
35 content = f"Report: {self._title}"
36 return self._exporter.export(content)
37
38# Usage — no new subclasses for new format × feature combinations.
39plain_pdf = Report("Q4 Revenue", PdfExporter())
40secure_csv = Report("Payroll", EncryptingExporter(CsvExporter(), key=b"secret"))
Structure at a glance
Report composes an Exporter rather than extending it. EncryptingExporter is itself an
Exporter, so it wraps any other exporter transparently. The hierarchy is two levels deep for
the implementations and stays that way forever, no matter how many formats or features you add.
When inheritance is the right call
Composition is not a blanket ban on inheritance. Use inheritance when subclasses genuinely are
a specialization of the parent — SavingsAccount extends BankAccount is legitimate because a
savings account is a bank account with additional rules, not just an account that happens to
behave differently in one dimension. The smell that signals "switch to composition" is when you
catch yourself naming a class after two orthogonal properties joined together:
EncryptedPdfReport, ReadOnlyAdminUser, CachedRemoteRepository. Each hyphenated noun is a
composition candidate hiding inside a subclass.
When choosing composition over inheritance, it is useful to articulate the combinatorial problem it avoids rather than simply naming the Strategy pattern. Identifying the structural problem clarifies the rationale for the design decision.