Composition vs Inheritance

intermediate composition inheritance has-a is-a delegation mixins

“Favor composition over inheritance” is one of the most repeated pieces of advice in OOP. But what does it actually mean, and when should we ignore it? Let’s break it down.

”Is-a” vs “Has-a”

These two phrases capture the fundamental difference:

  • Inheritance (is-a): A Dog IS an Animal. The child class IS a type of the parent.
  • Composition (has-a): A Car HAS an Engine. The class CONTAINS another object as a field.
# Inheritance: Dog IS an Animal
class Animal:
    def breathe(self):
        return "breathing"

class Dog(Animal):  # Dog is-a Animal
    def bark(self):
        return "Woof!"
# Composition: Car HAS an Engine
class Engine:
    def start(self):
        return "vroom"

class Car:
    def __init__(self):
        self.engine = Engine()  # Car has-a Engine

    def start(self):
        return self.engine.start()

The Problem with Deep Inheritance

Inheritance feels natural at first, but deep hierarchies create real problems.

Problem 1: Fragile Base Class — changing the parent class can break all children in unexpected ways.

Problem 2: Tight Coupling — child classes are bound to the parent’s implementation, not just its interface.

Problem 3: The Gorilla-Banana Problem — “You wanted a banana but got a gorilla holding the banana and the entire jungle.” We inherit everything, even what we don’t need.

# A deep hierarchy that's hard to reason about
class Vehicle:
    def move(self): ...
class MotorVehicle(Vehicle):
    def refuel(self): ...
class Car(MotorVehicle):
    def open_trunk(self): ...
class ElectricCar(Car):
    def refuel(self):  # wait, electric cars don't refuel...
        raise NotImplementedError  # awkward override

The ElectricCar problem shows how inheritance can force us into awkward situations. It inherited refuel() from MotorVehicle, but electric cars don’t refuel — they recharge.

Composition Fixes This

With composition, we pick and choose capabilities instead of inheriting a rigid hierarchy.

class GasEngine:
    def power(self):
        return "burning fuel"

class ElectricMotor:
    def power(self):
        return "using battery"

class Car:
    def __init__(self, powertrain):
        self.powertrain = powertrain  # inject the dependency

    def drive(self):
        return f"Driving by {self.powertrain.power()}"

gas_car = Car(GasEngine())
ev = Car(ElectricMotor())
print(ev.drive())  # Driving by using battery

No awkward inheritance. No overriding methods that don’t make sense. We just plug in what we need.

The Delegation Pattern

Delegation is the core mechanism of composition. Instead of inheriting behavior, we forward method calls to a contained object.

class Logger:
    def log(self, msg):
        print(f"[LOG] {msg}")

class UserService:
    def __init__(self):
        self._logger = Logger()  # delegate logging

    def create_user(self, name):
        self._logger.log(f"Creating user: {name}")
        return {"name": name}

The UserService doesn’t inherit from Logger — that would be weird (a user service IS NOT a logger). Instead, it holds a logger and delegates to it.

Mixins: A Middle Ground

Mixins are small, focused classes designed to be mixed into other classes via multiple inheritance. They add a single capability without being a full parent class.

class JsonMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)

class LogMixin:
    def log(self, msg):
        print(f"[{self.__class__.__name__}] {msg}")

class User(JsonMixin, LogMixin):
    def __init__(self, name, email):
        self.name = name
        self.email = email

u = User("Manish", "manish@example.com")
print(u.to_json())  # {"name": "Manish", "email": "manish@example.com"}
u.log("Created")    # [User] Created

Mixins work well when the behavior is orthogonal (unrelated to the class’s main purpose) and doesn’t carry state.

Refactoring: Inheritance to Composition

Let’s take a real example. Here’s an inheritance-based notification system:

# Before: Inheritance — inflexible
class Notifier:
    def send(self, msg):
        raise NotImplementedError

class EmailNotifier(Notifier):
    def send(self, msg):
        print(f"Email: {msg}")

class SlackNotifier(Notifier):
    def send(self, msg):
        print(f"Slack: {msg}")

# What if we need BOTH email AND Slack? Multiple inheritance? Yikes.

Now let’s refactor to composition:

# After: Composition — flexible
class EmailSender:
    def send(self, msg):
        print(f"Email: {msg}")

class SlackSender:
    def send(self, msg):
        print(f"Slack: {msg}")

class Notifier:
    def __init__(self, channels):
        self.channels = channels  # list of senders

    def notify(self, msg):
        for ch in self.channels:
            ch.send(msg)

notifier = Notifier([EmailSender(), SlackSender()])
notifier.notify("Server is down!")  # sends via BOTH channels

With composition, adding a new channel (SMS, webhook, whatever) is just creating a new class with a send() method. No inheritance changes needed.

Inheritance vs Composition
Inheritance Tree
Notifier
EmailNotifier
SlackNotifier
rigid • one channel per class
Composition
Notifier
↓ has
EmailSender
SlackSender
+ any sender
flexible • mix and match

When Inheritance IS the Right Call

Inheritance isn’t evil. It’s the right choice when:

  • There’s a true “is-a” relationship (a Cat really is an Animal)
  • We want to reuse a large chunk of behavior and the parent is stable
  • We’re building a framework that expects subclassing (like Django views, ABC-based contracts)
  • We need isinstance() checks against a common base class

The rule isn’t “never use inheritance.” It’s “don’t reach for inheritance as the default. Consider composition first.”

In simple language, inheritance means “I am a type of that thing,” while composition means “I have that thing inside me.” Composition is more flexible because we can mix and match behaviors at runtime without being locked into a rigid class tree. Use inheritance for true type relationships; use composition for everything else.