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:
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:
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:
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 priceDiscountCalculator holds a list of rules and applies each one in order, feeding the result of one rule into the next:
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 resultEach 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:
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:
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 TrueBoth 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:
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
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.
Frequently Asked Questions
When should I use inheritance instead of composition?
Does Python prefer composition over inheritance?
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.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.