Skip to content

Composition vs Inheritance

One of the most important design decisions in object-oriented programming is choosing between composition and inheritance to model relationships between classes. The classic Gang of Four advice — “favor composition over inheritance” — remains one of the most cited principles in software engineering. But what does it actually mean, and when should you still use inheritance?

”Is-A” vs “Has-A” Relationships

The fundamental distinction between inheritance and composition comes down to two types of relationships:

  • Inheritance (“Is-A”): A Dog is a Animal. The subclass is a specialized version of the parent class.
  • Composition (“Has-A”): A Car has a Engine. The containing class holds a reference to another object and delegates work to it.
Inheritance (Is-A): Composition (Has-A):
Animal Car
^ / \
| Engine Transmission
Dog / \
Cylinder FuelInjector

Quick Example: “Is-A” vs “Has-A”

# Inheritance: "Is-A" relationship
class Animal:
def __init__(self, name: str):
self.name = name
def eat(self):
return f"{self.name} is eating"
class Dog(Animal): # Dog IS AN Animal
def bark(self):
return f"{self.name} says Woof!"
# Composition: "Has-A" relationship
class Engine:
def __init__(self, horsepower: int):
self.horsepower = horsepower
def start(self):
return f"Engine with {self.horsepower}hp started"
class Car:
def __init__(self, model: str, engine: Engine): # Car HAS AN Engine
self.model = model
self.engine = engine
def start(self):
return f"{self.model}: {self.engine.start()}"
# Usage
dog = Dog("Buddy")
print(dog.eat()) # Buddy is eating
print(dog.bark()) # Buddy says Woof!
car = Car("Sedan", Engine(200))
print(car.start()) # Sedan: Engine with 200hp started

Problems with Deep Inheritance Hierarchies

Inheritance is a powerful mechanism, but when overused it creates several well-known problems.

The Fragile Base Class Problem

When a subclass depends on the implementation details of a base class, any change to the base class can break subclasses in unexpected ways.

# Fragile Base Class Problem
class HashSet:
def __init__(self):
self._count = 0
self._data = set()
def add(self, item):
self._count += 1
self._data.add(item)
def add_all(self, items):
# Internal detail: calls self.add() for each item
for item in items:
self.add(item)
def get_count(self):
return self._count
class InstrumentedHashSet(HashSet):
"""Tracks how many items have been added (including duplicates)."""
def add(self, item):
self._count += 1 # Count here...
super().add(item) # ...but super().add() ALSO counts!
def add_all(self, items):
self._count += len(items) # Count here...
super().add_all(items) # ...but super().add_all() calls self.add()!
# Bug: double-counting!
s = InstrumentedHashSet()
s.add_all(["a", "b", "c"])
print(s.get_count()) # Expected 3, but get 9!
# add_all adds 3, then calls super().add_all()
# which calls self.add() 3 times, each adding 2 (once in add, once in super)

The Diamond Problem

When a class inherits from two classes that share a common ancestor, ambiguity arises about which version of inherited methods to use.

Animal
/ \
Flyer Swimmer
\ /
FlyingFish <-- Which Animal constructor? Which eat()?
# Python handles the diamond problem with MRO (Method Resolution Order)
class Animal:
def __init__(self):
print("Animal init")
def eat(self):
return "Animal eats"
class Flyer(Animal):
def __init__(self):
super().__init__()
print("Flyer init")
def move(self):
return "Flying"
class Swimmer(Animal):
def __init__(self):
super().__init__()
print("Swimmer init")
def move(self):
return "Swimming"
class FlyingFish(Flyer, Swimmer):
def __init__(self):
super().__init__()
print("FlyingFish init")
# Python's MRO resolves the ambiguity, but the result can be surprising
ff = FlyingFish()
# Output: Animal init, Swimmer init, Flyer init, FlyingFish init
print(ff.move()) # "Flying" -- uses Flyer's version (first in MRO)
print(FlyingFish.__mro__)
# (<class 'FlyingFish'>, <class 'Flyer'>, <class 'Swimmer'>,
# <class 'Animal'>, <class 'object'>)

Other Inheritance Pitfalls

ProblemDescription
Tight CouplingSubclasses are bound to the parent’s implementation details and contract
Explosion of ClassesCombining features leads to a combinatorial explosion (e.g., RedCircle, BlueCircle, RedSquare, BlueSquare…)
Broken EncapsulationSubclasses often need access to parent’s internal state via protected members
Rigid HierarchiesChanging a class’s parent requires restructuring the entire hierarchy
Liskov Substitution ViolationsSubclasses that do not fully honor the parent’s contract cause subtle bugs
God Base ClassesShared base classes accumulate unrelated functionality over time

Composition Pattern Explained

Composition builds complex objects by combining simpler ones. Instead of saying “a Duck is a Bird that can fly and swim,” you say “a Duck has flying behavior and swimming behavior.”

The Classic Example: Game Characters

from abc import ABC, abstractmethod
# Define behaviors as separate classes
class MovementBehavior(ABC):
@abstractmethod
def move(self) -> str: ...
class AttackBehavior(ABC):
@abstractmethod
def attack(self) -> str: ...
# Concrete behaviors
class Walking(MovementBehavior):
def move(self) -> str:
return "Walking on foot"
class Flying(MovementBehavior):
def move(self) -> str:
return "Soaring through the sky"
class Swimming(MovementBehavior):
def move(self) -> str:
return "Swimming through water"
class SwordAttack(AttackBehavior):
def attack(self) -> str:
return "Slashing with sword"
class BowAttack(AttackBehavior):
def attack(self) -> str:
return "Shooting arrows"
class MagicAttack(AttackBehavior):
def attack(self) -> str:
return "Casting fireball"
# Character uses composition -- behaviors can be swapped at runtime
class Character:
def __init__(self, name: str,
movement: MovementBehavior,
attack: AttackBehavior):
self.name = name
self.movement = movement
self.attack = attack
def perform_move(self) -> str:
return f"{self.name}: {self.movement.move()}"
def perform_attack(self) -> str:
return f"{self.name}: {self.attack.attack()}"
def set_movement(self, movement: MovementBehavior):
"""Swap behavior at runtime!"""
self.movement = movement
def set_attack(self, attack: AttackBehavior):
"""Swap behavior at runtime!"""
self.attack = attack
# Usage
knight = Character("Knight", Walking(), SwordAttack())
print(knight.perform_move()) # Knight: Walking on foot
print(knight.perform_attack()) # Knight: Slashing with sword
# Knight picks up a magic staff -- swap attack behavior at runtime
knight.set_attack(MagicAttack())
print(knight.perform_attack()) # Knight: Casting fireball
# Knight drinks a flying potion
knight.set_movement(Flying())
print(knight.perform_move()) # Knight: Soaring through the sky

The Delegation Pattern

Delegation is a core technique in composition where an object hands off work to a contained helper object rather than implementing the logic itself. It achieves code reuse without inheritance.

class Printer:
def print_document(self, doc: str) -> str:
return f"Printing: {doc}"
class Scanner:
def scan_document(self) -> str:
return "Scanning document..."
class Fax:
def send_fax(self, doc: str, number: str) -> str:
return f"Faxing '{doc}' to {number}"
# MultiFunctionDevice delegates to specialized objects
class MultiFunctionDevice:
def __init__(self):
self._printer = Printer()
self._scanner = Scanner()
self._fax = Fax()
def print_document(self, doc: str) -> str:
return self._printer.print_document(doc) # Delegation
def scan_document(self) -> str:
return self._scanner.scan_document() # Delegation
def send_fax(self, doc: str, number: str) -> str:
return self._fax.send_fax(doc, number) # Delegation
def print_and_fax(self, doc: str, number: str) -> str:
"""Combine delegated operations into higher-level behavior."""
result_print = self._printer.print_document(doc)
result_fax = self._fax.send_fax(doc, number)
return f"{result_print}\n{result_fax}"
# Usage
mfd = MultiFunctionDevice()
print(mfd.print_document("Report.pdf"))
print(mfd.scan_document())
print(mfd.print_and_fax("Invoice.pdf", "555-1234"))

Why delegation over inheritance? If MultiFunctionDevice inherited from Printer, Scanner, and Fax, you would face the diamond problem (in C++), tight coupling to all three implementations, and difficulty testing each piece in isolation. With delegation, you can mock any component, swap implementations, and add new capabilities without modifying existing classes.


Mixins and Traits

Mixins and traits provide a way to reuse behavior across unrelated classes without traditional inheritance. They are a middle ground between full inheritance and manual composition.

# Mixins in Python use multiple inheritance with a convention:
# Mixin classes should not have __init__ and should be "mix-in-able"
class JsonSerializableMixin:
"""Mixin that adds JSON serialization capability."""
def to_json(self) -> str:
import json
return json.dumps(self.__dict__, default=str)
class LoggableMixin:
"""Mixin that adds logging capability."""
def log(self, message: str):
print(f"[{self.__class__.__name__}] {message}")
class TimestampMixin:
"""Mixin that adds timestamp tracking."""
def touch(self):
from datetime import datetime
self.updated_at = datetime.now()
# Combine mixins with a primary base class
class User(JsonSerializableMixin, LoggableMixin, TimestampMixin):
def __init__(self, name: str, email: str):
self.name = name
self.email = email
self.updated_at = None
def update_email(self, new_email: str):
self.log(f"Updating email from {self.email} to {new_email}")
self.email = new_email
self.touch()
class Product(JsonSerializableMixin, LoggableMixin):
def __init__(self, title: str, price: float):
self.title = title
self.price = price
# Usage
user = User("Alice", "alice@example.com")
user.update_email("alice.new@example.com")
# [User] Updating email from alice@example.com to alice.new@example.com
print(user.to_json())
# {"name": "Alice", "email": "alice.new@example.com", "updated_at": "2025-..."}
product = Product("Widget", 9.99)
product.log("Created new product")
# [Product] Created new product
print(product.to_json())
# {"title": "Widget", "price": 9.99}

When to Use Inheritance vs Composition

Decision Guide Table

CriteriaInheritanceComposition
Relationship is truly “is-a”Yes
Need to share interface (polymorphism)YesUse interfaces
Behaviors need to change at runtimeYes
Reusing implementation across unrelated classesYes
Building from multiple capability sourcesYes
Hierarchy is shallow (1-2 levels)YesEither
Hierarchy would be 3+ levels deepYes
Framework requires inheritance (e.g., UI widgets)Yes
Need to substitute subtype for parent (LSP)Yes
Want loose coupling and easy testingYes

Use Inheritance When

  1. There is a genuine “is-a” relationship that satisfies the Liskov Substitution Principle — a Square is a Shape, a HttpException is an Exception.
  2. You need polymorphic behavior — code that works with any Shape without knowing the specific type.
  3. The hierarchy is shallow and stable — you are confident the class tree will not grow beyond 2-3 levels.
  4. A framework mandates it — many UI frameworks (Android Views, Java Swing, Qt Widgets) require extending base classes.

Use Composition When

  1. You want to combine behaviors from multiple sources — a class needs capabilities from several unrelated classes.
  2. Behaviors need to change at runtime — swapping a movement strategy, changing a logging backend, etc.
  3. You want loose coupling — changing one component should not ripple through an inheritance chain.
  4. The “is-a” test fails or feels forced — a Stack uses a list, it is not a list.
  5. You want easier testing — composed dependencies can be mocked or stubbed independently.
  6. You are building across a team — composition makes it clearer which class owns which responsibility.

Refactoring from Inheritance to Composition

One of the most common refactoring tasks is replacing a fragile inheritance hierarchy with composition. Here is a before-and-after example.

Before: Inheritance-Based Notification System

# BEFORE: Rigid inheritance hierarchy
class Notification:
def __init__(self, message: str):
self.message = message
def send(self):
raise NotImplementedError
class EmailNotification(Notification):
def __init__(self, message: str, email: str):
super().__init__(message)
self.email = email
def send(self):
return f"Email to {self.email}: {self.message}"
class SMSNotification(Notification):
def __init__(self, message: str, phone: str):
super().__init__(message)
self.phone = phone
def send(self):
return f"SMS to {self.phone}: {self.message}"
# Problem: Now we need urgent notifications for BOTH email and SMS
# Do we create UrgentEmailNotification and UrgentSMSNotification?
# What about encrypted? Logged? The class count explodes!
class UrgentEmailNotification(EmailNotification):
def send(self):
return f"[URGENT] {super().send()}"
class UrgentSMSNotification(SMSNotification):
def send(self):
return f"[URGENT] {super().send()}"
# EncryptedUrgentEmailNotification? EncryptedUrgentSMSNotification?
# This does not scale.

After: Composition-Based Notification System

# AFTER: Flexible composition-based design
from abc import ABC, abstractmethod
from typing import List
# Sender strategy -- handles HOW to send
class NotificationSender(ABC):
@abstractmethod
def send(self, message: str) -> str: ...
class EmailSender(NotificationSender):
def __init__(self, email: str):
self.email = email
def send(self, message: str) -> str:
return f"Email to {self.email}: {message}"
class SMSSender(NotificationSender):
def __init__(self, phone: str):
self.phone = phone
def send(self, message: str) -> str:
return f"SMS to {self.phone}: {message}"
class SlackSender(NotificationSender):
def __init__(self, channel: str):
self.channel = channel
def send(self, message: str) -> str:
return f"Slack #{self.channel}: {message}"
# Message modifier -- transforms the message
class MessageModifier(ABC):
@abstractmethod
def modify(self, message: str) -> str: ...
class UrgentModifier(MessageModifier):
def modify(self, message: str) -> str:
return f"[URGENT] {message}"
class EncryptedModifier(MessageModifier):
def modify(self, message: str) -> str:
return f"[ENCRYPTED] {message}"
# Notification composes sender + modifiers
class Notification:
def __init__(self, message: str,
sender: NotificationSender,
modifiers: List[MessageModifier] = None):
self.message = message
self.sender = sender
self.modifiers = modifiers or []
def send(self) -> str:
msg = self.message
for modifier in self.modifiers:
msg = modifier.modify(msg)
return self.sender.send(msg)
# Usage: mix and match freely!
n1 = Notification("Server down",
EmailSender("ops@company.com"),
[UrgentModifier()])
print(n1.send())
# Email to ops@company.com: [URGENT] Server down
n2 = Notification("Secret report",
SlackSender("security"),
[EncryptedModifier(), UrgentModifier()])
print(n2.send())
# Slack #security: [URGENT] [ENCRYPTED] Secret report
n3 = Notification("Weekly update",
SMSSender("+1234567890"))
print(n3.send())
# SMS to +1234567890: Weekly update

What Changed?

AspectBefore (Inheritance)After (Composition)
Adding a new channelNew subclass per channel per featureOne new Sender class
Adding a new modifierExponential class explosionOne new Modifier class
Combining featuresNeed a class for each combinationCompose at runtime
TestingMust test entire hierarchyTest each component independently
Number of classes for 3 channels x 3 modifiersUp to 12+ classes6 classes + 1 compositor

Key Takeaways

  1. “Favor composition over inheritance” does not mean “never use inheritance.” It means composition should be your default choice, and inheritance should require justification.

  2. Inheritance is for “is-a” relationships that satisfy the Liskov Substitution Principle. If a subclass cannot fully stand in for its parent in all contexts, the relationship is not truly “is-a.”

  3. Composition is for “has-a” relationships and is more flexible because components can be swapped, combined, and tested independently.

  4. Deep inheritance hierarchies are a code smell. If your class tree goes more than 2-3 levels deep, it is a strong signal to refactor toward composition.

  5. The diamond problem, fragile base class problem, and class explosion are all symptoms of overusing inheritance. Composition avoids all three.

  6. Delegation is the mechanism that makes composition work — the containing object forwards calls to its components rather than inheriting their behavior.

  7. Mixins and traits offer a pragmatic middle ground when you need to share small, focused capabilities across unrelated classes.

  8. Refactoring from inheritance to composition is a common and valuable exercise that typically results in fewer classes, more flexibility, and better testability.


Practice Exercises

Exercise 1: Shape Drawing System

You are building a drawing application. Shapes can have different rendering strategies (SVG, Canvas, ASCII) and different fill patterns (solid, gradient, striped). Design a system using composition that avoids a class explosion.

Hints
  • Create a Renderer interface with implementations for SVG, Canvas, and ASCII.
  • Create a FillPattern interface with implementations for Solid, Gradient, and Striped.
  • A Shape class composes a Renderer and a FillPattern, along with shape-specific data (radius, width, height).
  • You should be able to create any combination (e.g., an SVG circle with gradient fill) without creating a dedicated class for that combination.

Exercise 2: Refactor the Animal Kingdom

The following inheritance hierarchy has become unmanageable. Refactor it to use composition.

Animal
├── Bird
│ ├── Eagle (can fly, carnivore)
│ ├── Penguin (cannot fly, swims, carnivore)
│ └── Parrot (can fly, herbivore, can mimic speech)
├── Mammal
│ ├── Dog (walks, carnivore, can be domesticated)
│ ├── Whale (swims, carnivore)
│ └── Bat (can fly, carnivore)
└── Fish
├── Salmon (swims, carnivore)
└── FlyingFish (swims, can glide/fly)
Hints
  • Identify the behaviors that cut across the hierarchy: locomotion (walk, fly, swim), diet (carnivore, herbivore), special abilities (mimic speech, domesticable).
  • Model each behavior axis as a separate interface/class.
  • Create an Animal class that composes LocomotionBehavior, DietBehavior, and optionally SpecialAbility.
  • A Bat is an Animal with Flying locomotion and Carnivore diet — no need for it to be a Mammal subclass just because of taxonomy.

Exercise 3: Logger Pipeline

Design a composable logging system where:

  • Logs can be sent to multiple destinations (console, file, remote server).
  • Logs can pass through multiple filters (minimum severity, keyword blocklist).
  • Logs can be formatted in different ways (plain text, JSON, structured).

Use composition so that any combination of destination, filter, and format can be assembled without creating dedicated subclasses for each combination.

Hints
  • Define LogDestination, LogFilter, and LogFormatter interfaces.
  • A Logger composes one LogFormatter, zero or more LogFilter instances, and one or more LogDestination instances.
  • Processing pipeline: check filters -> format message -> send to all destinations.
  • Adding a new destination (e.g., Slack webhook) should require only one new class implementing LogDestination.

Next Steps