Composition over inheritance
Why deep inheritance hierarchies tend to crack under real requirements, worked through a concrete example that breaks and the composition-based fix.
2 min read
Where inheritance starts to crack
class Bird:
def fly(self):
print("flying")
def make_sound(self):
print("tweet")
class Duck(Bird):
pass
class Penguin(Bird):
def fly(self):
raise NotImplementedError("penguins can't fly") # inherited a promise it can't keepPenguin is a Bird in the real-world sense, but Bird.fly() made a promise that not every bird can keep. Every base-class method is an implicit promise to every subclass: "you can do this too." The moment a real-world category has an exception — flightless birds, in this case — that promise breaks, and it breaks in a way that's invisible until something actually calls .fly() on a Penguin at runtime. This is the same underlying failure as the Rectangle/Square problem from the SOLID lesson: an inheritance relationship that felt obviously true in plain English didn't hold up as a behavioral contract.
The composition-based fix
Instead of inheriting behavior, build an object out of smaller pieces that each provide one capability — and only wire in the capabilities that actually apply:
class FlyingBehavior:
def move(self):
print("flying")
class SwimmingBehavior:
def move(self):
print("swimming")
class Bird:
def __init__(self, movement: "FlyingBehavior | SwimmingBehavior"):
self.movement = movement
def move(self):
self.movement.move()
duck = Bird(FlyingBehavior())
penguin = Bird(SwimmingBehavior())
duck.move() # "flying"
penguin.move() # "swimming" — no broken promise, no NotImplementedErrorThere's no Penguin class overriding a method it can't honor. Bird doesn't claim every bird can fly — it delegates movement to whatever behavior object it was built with. Adding a new movement style (waddling, gliding) is adding a new behavior class, not editing Bird or auditing every existing subclass for what it's allowed to inherit.
The actual trade-off, honestly
Composition isn't strictly better in every case — it's a different set of costs:
- Inheritance gives you a lot "for free" through the base class, with minimal boilerplate for the common case — cheap when the hierarchy genuinely is stable and every subtype really does honor every inherited promise.
- Composition requires explicitly wiring the pieces together (as seen in
Bird.__init__above) — more upfront structure, but each piece stays independently testable, and a new combination of behaviors never risks breaking an existing one.
The practical rule of thumb: reach for inheritance for genuinely stable "is-a" relationships with shared, unconditional behavior (a Circle and Square are both unconditionally a Shape with an area()). Reach for composition the moment you catch yourself writing a subclass that overrides a method to throw, do nothing, or otherwise opt out of what its parent promised — that's the concrete signal the hierarchy has cracked.
Further reading
- Wikipedia — Composition over inheritance
- Wikipedia — Liskov substitution principle, the formal statement of the "broken promise" problem shown above.
Check your understanding
A quick comprehension check — not tracked, not graded, just for you.
1. Why does Penguin inheriting fly() from Bird cause a real problem?
2. In the composition-based fix, how does Bird decide how to move?
3. What's the honest trade-off between inheritance and composition, per this lesson?
4. What's the concrete signal that a hierarchy has cracked and composition should replace it?