Beyond the Hierarchy: Mastering Composition Over Inheritance in Python

Overview

Software design often forces a choice between two fundamental relationships: is-a and has-a. While inheritance allows a class to derive behavior from a parent, composition involves building complex objects by combining simpler, independent pieces. This guide demonstrates how to refactor rigid inheritance hierarchies into flexible, composed systems. By favoring composition, you reduce the strong coupling that often makes deep inheritance trees brittle and difficult to maintain. We will transform a cluttered employee payment system into a modular architecture where responsibilities like personnel data, payment logic, and commission structures remain distinct and interchangeable.

Prerequisites

To follow this tutorial, you should have a solid grasp of

basics, including classes and methods. Familiarity with
Object-Oriented Programming
concepts like classes and subclasses is necessary. Understanding the basics of
Abstract Base Classes
will help in understanding how we define interfaces.

Key Libraries & Tools

  • abc: Python's built-in module for defining Abstract Base Classes, used to enforce method implementation in subclasses.
  • dataclasses: A utility for automatically generating boilerplate code like __init__ for data-heavy classes.
  • typing: Specifically the Optional type, used to handle components that may not exist for every instance.

Code Walkthrough

Beyond the Hierarchy: Mastering Composition Over Inheritance in Python
Composition Is Better Than Inheritance in Python

The Problem: Inheritance Explosion

Initially, developers often reach for inheritance. If you have an Employee and need to add a commission, you might create a SalariedEmployeeWithCommission. When you add a bonus, you create a SalariedEmployeeWithCommissionAndBonus. This leads to a combinatorial explosion of subclasses.

The Solution: Modular Composition

First, we define our interfaces using

. Instead of one giant hierarchy, we create separate modules for different responsibilities.

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional

class Contract(ABC):
    @abstractmethod
    def get_payment(self) -> float:
        pass

class Commission(ABC):
    @abstractmethod
    def get_payment(self) -> float:
        pass

Next, we implement concrete versions of these components. This isolates the logic for hourly pay from the logic for sales commissions.

@dataclass
class HourlyContract(Contract):
    pay_rate: float
    hours_worked: float
    employer_cost: float = 1000

    def get_payment(self) -> float:
        return self.pay_rate * self.hours_worked + self.employer_cost

@dataclass
class ContractCommission(Commission):
    commission_amount: float
    contracts_landed: int

    def get_payment(self) -> float:
        return self.commission_amount * self.contracts_landed

Finally, the Employee class becomes a container. It doesn't care how pay is calculated; it simply delegates that task to its composed components.

@dataclass
class Employee:
    name: string
    id: int
    contract: Contract
    commission: Optional[Commission] = None

    def compute_pay(self) -> float:
        payout = self.contract.get_payment()
        if self.commission:
            payout += self.commission.get_payment()
        return payout

Syntax Notes

We utilize the @dataclass decorator to keep the code clean and readable, removing the need for manual __init__ methods. The Optional[Commission] syntax indicates that while every employee must have a contract, the commission component is a flexible add-on. This pattern adheres to the

principle, as the Employee depends on the Contract abstraction rather than a concrete implementation.

Practical Examples

This pattern is highly effective in

applications where payment rules change frequently. By swapping a SalaryContract for an HourlyContract at runtime, you can change an object's behavior without modifying its class. It is also prevalent in game development (ECS patterns) where entities gain abilities by adding components rather than inheriting from a "Super Warrior" class.

Tips & Gotchas

Avoid deep inheritance chains; most design patterns from the

rarely exceed one or two levels. Inheritance creates the strongest possible coupling in OOP, meaning a change in the parent can silently break every child. If you find yourself using super() to constantly patch behavior, you likely need composition. Use
Abstract Base Classes
to define clear boundaries between your system's modules.

4 min read