Facebook Pixel

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.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro