engineering

Composition Over Inheritance: Stop Building Hierarchies You'll Regret

TL;DR: Inheritance models what something is. Composition models what something does. The moment your objects need to mix behaviors, inheritance turns into a mess of deep hierarchies and copy-pasted code. Composition keeps behaviors small, swappable, and independently testable.


Inheritance looks elegant on a whiteboard. A Dog extends Animal. A Manager extends Employee. Clean, obvious, done.

Then requirements change. Now you need a RemoteManager. Does it extend Manager? RemoteEmployee? Both? You don't have both, so you make both, and suddenly you're maintaining a tree when you needed a graph.

That's the problem composition solves.

What goes wrong with inheritance

Take a notification system. You start simple:

class Notifier:
    def send(self, message): ...

class EmailNotifier(Notifier):
    def send(self, message):
        # send email

class SMSNotifier(Notifier):
    def send(self, message):
        # send SMS

Product asks for logging. You add it:

class LoggedEmailNotifier(EmailNotifier):
    def send(self, message):
        log(message)
        super().send(message)

class LoggedSMSNotifier(SMSNotifier):
    def send(self, message):
        log(message)
        super().send(message)

Now they want retry logic. And rate limiting. And a push notification channel.

Every new behavior multiplies the number of classes. Every new channel means re-implementing every behavior combination. You're not modeling a system anymore — you're managing a taxonomy.

This is the combinatorial explosion problem. n behaviors × m channels = n × m classes, all of them carrying duplicated logic.

Composition: behaviors as objects

Instead of baking behaviors into the hierarchy, make them their own things:

class Notifier:
    def __init__(self, channel, middlewares=()):
        self.channel = channel
        self.middlewares = list(middlewares)

    def send(self, message):
        for mw in self.middlewares:
            message = mw.process(message)
        self.channel.send(message)


class EmailChannel:
    def send(self, message): ...

class SMSChannel:
    def send(self, message): ...


class Logger:
    def process(self, message):
        print(f"[log] {message}")
        return message

class RateLimiter:
    def __init__(self, limit):
        self.limit = limit
        self.count = 0

    def process(self, message):
        self.count += 1
        if self.count > self.limit:
            raise RuntimeError("rate limit exceeded")
        return message

Now you assemble what you need at the call site:

notifier = Notifier(
    channel=EmailChannel(),
    middlewares=[Logger(), RateLimiter(limit=100)],
)
notifier.send("Your order shipped.")

Adding push notifications? Add PushChannel. Adding retry logic? Add a Retrier middleware. Nothing else changes. n behaviors + m channels = n + m classes, not n × m.

The real difference

Inheritance Composition
Adding a behavior Subclass every affected class Write one new class
Swapping a behavior at runtime Can't Just swap the object
Testing a behavior in isolation Requires instantiating the parent Test the behavior class alone
Code reuse Via super() chains Via direct delegation

Inheritance couples what you are to what you can do. Composition separates them.

When inheritance is actually fine

Inheritance isn't wrong — it's just misused.

Use it when the subclass genuinely is a more specific version of the parent and you want to share interface guarantees. Python's Exception hierarchy is a good example: ValueError is an Exception, not something that has exception behavior. The is-a relationship holds cleanly, and you're not mixing orthogonal concerns.

Rule of thumb: If you're inheriting to reuse behavior, reach for composition. If you're inheriting to enforce an interface, inheritance is fine.

Abstract base classes fall into the second category — they define a contract, not an implementation. Using ABC to say "every Channel must implement send()" is inheritance done right.

Key takeaways

  • Inheritance models identity; composition models capability. When you need to mix capabilities, inheritance creates an exponential class explosion.
  • Behaviors as objects means you can add, remove, and reorder them without touching existing code.
  • Composition enables runtime flexibility — you can swap a logger for a no-op logger in tests without subclassing anything.
  • The test signal: if you can't test a behavior without instantiating its parent class, it's probably in the wrong place.
  • Inheritance still has a job: enforcing interfaces and modeling true is-a relationships. Don't throw it out — just stop using it as a code-sharing mechanism.
Thoughts? Leave a comment