Composition Over Inheritance in Python

Learn when to use composition instead of inheritance in Python, with practical examples that show how combining objects leads to more flexible and maintainable code.

7 min read

Composition means building complex objects by combining simpler ones, rather than by extending a base class through inheritance. An object that has a database connection, a logger, and a cache is composed of those three pieces. It does not inherit from a DatabaseConnector, a Logger, and a Cache base class.

This principle is often summarized as "prefer has-a over is-a." A car has an engine; it does not inherit from Engine. A report generator has a file writer; it does not extend FileWriter. This article shows practical Python patterns for composition, building on the class fundamentals covered in object-oriented programming in Python explained.

The problem with deep inheritance

Inheritance creates a rigid hierarchy. When you extend a base class, you inherit all of its behavior, and changing the base class affects every subclass. Over time, as more behavior is pushed into base classes, the hierarchy becomes fragile.

Consider an e-commerce system where discounts are modeled through inheritance. Each new discount type requires a new subclass:

pythonpython
class Discount:
    def apply(self, price):
        return price
 
class PercentageDiscount(Discount):
    def __init__(self, percent):
        self.percent = percent
 
    def apply(self, price):
        return price * (1 - self.percent / 100)
 
class FlatDiscount(Discount):
    def __init__(self, amount):
        self.amount = amount
 
    def apply(self, price):
        return max(0, price - self.amount)

Now imagine needing a seasonal percentage discount with a minimum purchase. A new subclass or a mixin complicates the hierarchy further. The inheritance tree grows with every new requirement.

Composition: build from small pieces

With composition, a discount is not a class hierarchy. It is an object that holds a list of discount rules, each responsible for one transformation. Adding a new rule type does not touch any existing code:

pythonpython
class PercentageRule:
    def __init__(self, percent):
        self.percent = percent
 
    def apply(self, price):
        return price * (1 - self.percent / 100)
 
 
class FlatRule:
    def __init__(self, amount):
        self.amount = amount
 
    def apply(self, price):
        return max(0, price - self.amount)

A MinimumPurchaseRule can go one step further and compose another rule instead of doing its own math. It only forwards the price to the wrapped rule when the order meets the minimum, and lets the price pass through unchanged otherwise:

pythonpython
class MinimumPurchaseRule:
    def __init__(self, minimum, rule):
        self.minimum = minimum
        self.rule = rule
 
    def apply(self, price):
        if price >= self.minimum:
            return self.rule.apply(price)
        return price

DiscountCalculator holds a list of rules and applies each one in order, feeding the result of one rule into the next:

pythonpython
class DiscountCalculator:
    def __init__(self, rules=None):
        self.rules = rules or []
 
    def apply(self, price):
        result = price
        for rule in self.rules:
            result = rule.apply(result)
        return result

Each rule is a small, self-contained class with one method. The DiscountCalculator composes them into a pipeline. New rules, including rules that wrap other rules, are added without modifying existing code. Testing each rule in isolation is trivial because it has no dependencies on a parent class.

Dependency injection for testability

Composition makes dependency injection natural. Instead of hard-coding a dependency inside a class, pass it through the constructor. The class declares what it needs, and the caller provides it:

pythonpython
class OrderProcessor:
    def __init__(self, payment_gateway, inventory_service, notifier):
        self.payment_gateway = payment_gateway
        self.inventory_service = inventory_service
        self.notifier = notifier
 
    def process(self, order):
        self.payment_gateway.charge(order.total)
        self.inventory_service.reserve(order.items)
        self.notifier.send_confirmation(order.customer_email)

In tests, you pass mock objects that simulate the dependencies. In production, you pass real implementations. The OrderProcessor does not care which it receives as long as each object has the expected interface.

Protocols for defining interfaces

Python's typing.Protocol lets you define an interface without requiring inheritance. Any class that has the right methods satisfies the protocol, even if it does not explicitly declare that it implements it:

pythonpython
from typing import Protocol
 
 
class PaymentGateway(Protocol):
    def charge(self, amount: float) -> bool:
        """Charge the given amount. Return True on success."""
 
 
class StripeGateway:
    def charge(self, amount: float) -> bool:
        # Real Stripe integration
        return True
 
 
class MockGateway:
    def charge(self, amount: float) -> bool:
        return True

Both StripeGateway and MockGateway satisfy the PaymentGateway protocol without inheriting from it. The type checker verifies that any object passed where a PaymentGateway is expected has a charge method with the right signature.

When inheritance is still the right choice

Composition is not an absolute rule. Inheritance is the right tool in specific situations.

Exceptions should inherit from Exception or a more specific built-in type. This makes them catchable by standard error handlers and gives them proper traceback behavior:

pythonpython
class PaymentFailedError(Exception):
    """Raised when a payment cannot be processed."""

Framework base classes like Django models or SQLAlchemy declarative bases use inheritance to wire up database behavior. The framework expects you to extend its base class.

Mixins provide reusable chunks of behavior that can be combined through multiple inheritance. A SerializerMixin that adds to_dict and to_json methods is useful across unrelated classes. Keep mixins small and focused on one concern.

For most application code, start with composition. It is easier to add inheritance later than to remove it from a deep hierarchy. For more on class design, see building reusable Python classes.

Rune AI

Rune AI

Key Insights

  • Composition builds objects by combining smaller components rather than extending a base class.
  • Prefer has-a relationships over is-a relationships for application code.
  • Dependency injection makes composed objects testable and swappable.
  • Use protocol classes to define interfaces without requiring inheritance.
  • Inheritance still has its place for frameworks, exceptions, and genuine type hierarchies.
RunePowered by Rune AI

Frequently Asked Questions

When should I use inheritance instead of composition?

Use inheritance when there is a genuine is-a relationship and the child class needs to be used wherever the parent class is expected. The classic example is a custom exception inheriting from Exception. If you are unsure, start with composition. It is easier to switch to inheritance later than to untangle a deep hierarchy.

Does Python prefer composition over inheritance?

Python does not enforce either approach, but the community generally favors composition for application code because it leads to more flexible and testable designs. Inheritance is still useful for frameworks, mixins, and cases where polymorphic dispatch through a common interface is the right abstraction.

Conclusion

Composition is not a rejection of object-oriented programming. It is a more flexible way to build objects by combining smaller pieces instead of locking them into rigid hierarchies. Start with composition, reach for inheritance when the abstraction genuinely fits, and your code will be easier to change.