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.

Intermediate

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 keep

Penguin 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 NotImplementedError

There'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

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?