DEV Community

Cover image for Golden Rule: Composition Over Inheritance
Maksym
Maksym

Posted on

Golden Rule: Composition Over Inheritance

A practical guide to writing flexible, maintainable code


Introduction

Every object-oriented developer eventually faces the same dilemma: two classes share some behaviour, and the natural instinct is to create a parent class and have them both inherit from it. It feels clean. It feels right. But more often than not, a few months down the road, that hierarchy starts to crack under the weight of new requirements.

This article explores why favour composition over inheritance — one of the most important principles in software design — what it means in practice, and how to apply it in your everyday code.


What Is Inheritance?

Inheritance is a mechanism where one class (the child or subclass) derives behaviour and state from another class (the parent or superclass). The child extends the parent, inheriting its methods and fields, and can override or add to them.

A classic textbook example:

class Animal:
    def speak(self):
        raise NotImplementedError

class Dog(Animal):
    def speak(self):
        return "Woof!"

class Cat(Animal):
    def speak(self):
        return "Meow!"
Enter fullscreen mode Exit fullscreen mode

This looks perfectly fine. And for simple taxonomies, it often is. The problem appears when real-world requirements stop behaving like a tidy class diagram.


The Problems with Deep Hierarchies

The Fragile Base Class Problem

When you change a base class, you risk breaking every subclass. Because subclasses depend on the internal implementation of the parent — not just its public interface — even a small refactor can have cascading, unexpected side effects.

The Gorilla–Banana Problem

"You wanted a banana but what you got was a gorilla holding the banana and the entire jungle." — Joe Armstrong

Inheritance forces you to take everything the parent offers, whether you need it or not. If a subclass only needs one method from a parent that has twenty, it still inherits all the state and behaviour — along with all the associated complexity and risk.

The Diamond Problem

What happens when a class needs to inherit behaviour from two different parents that both define the same method? Most languages either forbid this entirely or introduce complicated resolution rules. It is a symptom of a deeper design flaw.

Violation of Encapsulation

Subclasses are intimately coupled to their parents. They often need to know about protected internals, call super() in the right order, and respect implicit contracts that are never written down. This tight coupling makes code hard to understand and refactor independently.


What Is Composition?

Composition means building complex behaviour by combining simpler, focused objects — rather than by extending a class hierarchy. Instead of asking "what is this object?", you ask "what does this object do?"

Where inheritance models an is-a relationship, composition models a has-a relationship.

Approach Relationship Example
Inheritance is-a Dog is an Animal
Composition has-a Robot has a Speaker

A Side-by-Side Comparison

Imagine you are building a game with different types of characters. Some can fly, some can swim, some can fight — and some can do combinations of all three.

The Inheritance Approach (and its problems)

class Character:
    pass

class FlyingCharacter(Character):
    def fly(self): ...

class SwimmingCharacter(Character):
    def swim(self): ...

# What about a character that can BOTH fly and swim?
# Most languages won't allow this cleanly.
class FlyingSwimmingCharacter(FlyingCharacter, SwimmingCharacter):
    pass  # Multiple inheritance — this gets messy fast
Enter fullscreen mode Exit fullscreen mode

Every new combination of abilities requires a new class. The hierarchy explodes in complexity.

The Composition Approach

class FlyBehaviour:
    def fly(self):
        print("Soaring through the sky!")

class SwimBehaviour:
    def swim(self):
        print("Gliding through the water!")

class FightBehaviour:
    def fight(self):
        print("Attacking!")

class Character:
    def __init__(self, behaviours):
        self.behaviours = behaviours

    def perform(self, action):
        behaviour = self.behaviours.get(action)
        if behaviour:
            getattr(behaviour, action)()

# Build any combination at will
hero = Character({
    "fly":   FlyBehaviour(),
    "swim":  SwimBehaviour(),
    "fight": FightBehaviour(),
})

hero.perform("fly")   # Soaring through the sky!
hero.perform("swim")  # Gliding through the water!
Enter fullscreen mode Exit fullscreen mode

Adding a new ability now means writing one new class — not reshaping the entire hierarchy.


Composition in the Real World

Strategy Pattern

The Strategy pattern is composition in its most recognisable form. You define a family of algorithms, encapsulate each one, and make them interchangeable.

class JSONSerializer:
    def serialize(self, data):
        import json
        return json.dumps(data)

class XMLSerializer:
    def serialize(self, data):
        # ... XML serialization logic
        return f"<data>{data}</data>"

class DataExporter:
    def __init__(self, serializer):
        self.serializer = serializer  # composed, not inherited

    def export(self, data):
        return self.serializer.serialize(data)

exporter = DataExporter(JSONSerializer())
exporter.export({"name": "Alice"})

# Swap the strategy without touching DataExporter
exporter.serializer = XMLSerializer()
exporter.export({"name": "Alice"})
Enter fullscreen mode Exit fullscreen mode

Dependency Injection

Dependency injection is composition by another name. Rather than a class constructing its own dependencies (or inheriting them), they are passed in from the outside. This keeps classes loosely coupled and trivially testable.

class EmailNotifier:
    def notify(self, message):
        print(f"Sending email: {message}")

class SMSNotifier:
    def notify(self, message):
        print(f"Sending SMS: {message}")

class OrderService:
    def __init__(self, notifier):
        self.notifier = notifier  # injected dependency

    def place_order(self, order):
        # ... process order
        self.notifier.notify(f"Order {order.id} confirmed.")

# In tests, swap for a mock — no subclassing needed
service = OrderService(EmailNotifier())
Enter fullscreen mode Exit fullscreen mode

When Is Inheritance Actually Appropriate?

Composition over inheritance does not mean never use inheritance. Inheritance is a valid and useful tool when the relationship genuinely satisfies all of the following:

  • The is-a relationship is stable and true across all use cases — Square is a Shape, HTTPException is an Exception.
  • You want to share a genuine, semantic contract, not just re-use some code.
  • The hierarchy is shallow — ideally no more than two levels deep.
  • You are extending a framework or library that is specifically designed to be subclassed (e.g. Django's View classes, Python's unittest.TestCase).

A useful heuristic: if you would feel awkward saying "X is a kind of Y" out loud to a non-programmer, reach for composition instead.


The Liskov Substitution Principle

The Liskov Substitution Principle (LSP) — the L in SOLID — states that objects of a subclass should be substitutable for objects of their superclass without altering the correctness of the program.

The classic violation:

class Rectangle:
    def set_width(self, w): self.width = w
    def set_height(self, h): self.height = h
    def area(self): return self.width * self.height

class Square(Rectangle):
    def set_width(self, w):
        self.width = w
        self.height = w  # A square must keep sides equal

    def set_height(self, h):
        self.width = h   # This breaks Rectangle's contract!
        self.height = h
Enter fullscreen mode Exit fullscreen mode

A Square seems like it should be a Rectangle, but the subclass breaks the parent's behavioural contract. Any code that relies on being able to set width and height independently will malfunction when handed a Square.

If your inheritance violates LSP, it is a strong signal that you need composition instead.


Summary

Inheritance Composition
Relationship is-a has-a
Coupling Tight (shares internals) Loose (uses interface)
Flexibility Low (hierarchy is fixed) High (swap behaviours at runtime)
Reuse unit Entire class Individual behaviours
Testability Harder (must instantiate hierarchy) Easier (inject mocks)
Risk of change High (fragile base class) Low (changes are localised)

Inheritance is a powerful tool, but it is one that is easy to misuse. The moment you find yourself creating a subclass primarily to re-use some code — rather than to express a genuine taxonomic relationship — it is time to reach for composition.

Prefer composition. Build your objects from small, focused, interchangeable pieces. Your future self, and everyone else who reads the code, will thank you.

Top comments (0)