DEV Community

Cover image for Why Is Composition Often Better Than Inheritance?
Aditya Sharma
Aditya Sharma

Posted on

Why Is Composition Often Better Than Inheritance?

Start with something simple. You're modelling birds.

class Bird:
    def fly(self):
        print("Flying")

class Eagle(Bird):
    pass

class Penguin(Bird):
    pass
Enter fullscreen mode Exit fullscreen mode

Eagle looks fine. Penguin doesn't. A penguin is a bird, but it can't fly. So now what? You override fly in Penguin to do nothing, or raise an exception, or print "I can't fly." All of these feel wrong because they are wrong. You've given Penguin a method it shouldn't have, then tried to undo it.

This is a small example, but the problem gets worse as the application grows.


When the Hierarchy Starts Fighting You

Say the application now has Eagles, Penguins, Ducks, and Ostriches. Each has a different combination of capabilities:

  • Eagle: flies, walks
  • Penguin: walks, swims
  • Duck: flies, walks, swims
  • Ostrich: walks The natural instinct is to keep solving this with inheritance. Maybe you split Bird into FlyingBird and NonFlyingBird. But then Duck needs to swim, and Penguin needs to swim, and they're in different branches of the hierarchy. So you add a SwimmingBird. But Duck also flies. Now you're either duplicating behaviour or reaching for multiple inheritance, which opens its own set of problems.

The hierarchy is getting complicated not because the problem is complicated, but because you're trying to encode everything into a parent-child tree.

The deeper issue is that flying, walking, and swimming are independent capabilities. A bird might have any combination of them. Inheritance forces you to express that through a single chain of parent classes, and a chain can only go in one direction.


Why Inheritance Couples You to the Parent

Inheritance creates a strong relationship between a child class and its parent. The child gets everything the parent has, whether it needs it or not. If the parent changes, children are affected. If the parent grows to accommodate the needs of one subclass, it can start leaking behaviour that other subclasses don't need.

The problem isn't that inheritance is useless. It's that it bundles two separate ideas together: subtype polymorphism (a Penguin can be used wherever a Bird is expected) and behaviour reuse (a Penguin gets Bird's methods for free). When you only need one of these, inheritance can still make sense. But when the behaviour you're reusing doesn't fit cleanly, the coupling becomes a liability.


What If Behaviour Didn't Have to Come From the Parent?

Instead of inheriting capabilities, what if each bird was given the capabilities it actually needs?

class FlyingMovement:
    def move(self):
        print("Flying")

class WalkingMovement:
    def move(self):
        print("Walking")

class SwimmingMovement:
    def move(self):
        print("Swimming")

class Bird:
    def __init__(self, movement):
        self.movement = movement

    def move(self):
        self.movement.move()
Enter fullscreen mode Exit fullscreen mode

Now:

eagle = Bird(FlyingMovement())
penguin = Bird(WalkingMovement())
Enter fullscreen mode Exit fullscreen mode

Duck needs multiple capabilities, so you'd extend this slightly, but the key idea is already visible: the behaviour lives in a separate object that gets composed into the bird rather than inherited from a parent.

If you add a new bird tomorrow that can fly but not swim, you don't touch the existing hierarchy. You just compose it differently. The behaviour varies independently from the type.


Why This Makes Change Easier

The practical benefit shows up when requirements change.

With inheritance, adding a new capability often means modifying a parent class or restructuring the hierarchy. Either can affect classes you didn't intend to touch. Tests that were passing can start failing. The blast radius of a change is hard to predict.

With composition, you can change one behaviour object without affecting anything else. You can swap out a movement strategy without touching Bird. You can test FlyingMovement in complete isolation. If a new requirement means Penguins should now be able to glide short distances, you can introduce a GlidingMovement without reorganising the entire bird hierarchy.

Each piece of behaviour is a small, independent unit. You assemble what you need from those units rather than inheriting a bundle and spending effort removing what you don't need.


When Inheritance Still Makes Sense

None of this means inheritance is wrong.

Consider shapes:

class Shape:
    def area(self):
        pass

class Circle(Shape):
    def area(self):
        return 3.14 * self.radius ** 2

class Rectangle(Shape):
    def area(self):
        return self.width * self.height
Enter fullscreen mode Exit fullscreen mode

Every Circle genuinely is a Shape. The subtype relationship is real and stable. Polymorphism is genuinely useful here: you can pass a Circle or a Rectangle wherever a Shape is expected, and the right area method gets called. The hierarchy isn't fighting the design, it's expressing it.

Inheritance works well when the subtype relationship is meaningful, the shared interface is stable, and the behaviour you're inheriting is actually the behaviour you need. When all of those are true, using it is reasonable.

The problem is when inheritance gets used as a mechanism for code reuse even when the subtype relationship isn't clean, because it's the first tool that comes to mind.


The Broader Lesson

Inheritance says: this object gets behaviour because it belongs to this hierarchy.

Composition says: this object gets behaviour because we gave it this component.

That distinction might seem subtle in a small example. In a large codebase with dozens of classes and six levels of inheritance, it becomes the difference between a design that accommodates change and one that resists it.

When you find yourself adding methods to a parent class to serve one specific child, or overriding methods just to disable inherited behaviour, or drawing an inheritance tree that keeps needing new branches, it's worth pausing.

What if the object didn't inherit this behaviour at all? What if it was composed from the pieces it actually needs?

That question, asked early enough, can save a lot of refactoring later.

Top comments (0)