← Python Code Modern OOP
Browse Python Concepts

Composition Over Inheritance in Python

Mental Model

Imagine inheritance as a single, shared pipeline where different segments (methods) might have their own side effects. Calling super() means you're sending data further down the pipeline, potentially re-triggering side effects in segments that have already been modified by a subclass.

Rule: When building complex behaviors, prefer composition over inheritance to isolate implementation details and avoid unexpected dynamic method dispatch side effects.

The Setup

You are building a custom logging metric repository. The base class increments counters and delegates to a write function. You decide to inherit from this base class to create an optimization layer that buffers metrics, but your override unexpectedly double-counts tracking metrics.

What Does This Print?

Broken code
Python
class MetricRepository:
    def __init__(self):
        self.metrics = {}

    def add_metric(self, key: str, value: float):
        self.metrics[key] = value

    def add_batch(self, batch_data: dict):
        for k, v in batch_data.items():
            self.add_metric(k, v)

class CountingRepository(MetricRepository):
    def __init__(self):
        super().__init__()
        self.write_count = 0

    def add_metric(self, key: str, value: float):
        self.write_count += 1
        super().add_metric(key, value)

    def add_batch(self, batch_data: dict):
        self.write_count += len(batch_data)
        super().add_batch(batch_data)

repo = CountingRepository()
repo.add_batch({"latency": 12.5, "cpu": 88.0})
print(repo.write_count)
What will the code output for write_count? Will it show 2 because two metrics were added, or something else?

The Output

What actually happens
4

The code outputs 4 instead of 2. The subclass CountingRepository implements add_batch and increments write_count by the collection length (which is 2). It then delegates control to the parent's add_batch via super(). However, the parent's add_batch loops over the keys and calls self.add_metric(). Because of dynamic binding, self points to the subclass, executing the overridden add_metric method and adding another increment for each item.

Why Python Does This

In Python, methods are resolved dynamically at runtime through dynamic dispatch (late binding). When super().add_batch(batch_data) is executed, the context execution pointer self remains tied to the active subclass instance CountingRepository. Consequently, when the parent class's bytecode references self.add_metric, the interpreter starts its lookup at the subclass's namespaces, triggering the subclass method override rather than resolving to its own class definition. This is the classic Fragile Base Class problem.

The Fix

Corrected pattern
Python
# Re-architecting utilizing composition to completely isolate state behaviors
class MetricRepository:
    def __init__(self):
        self.metrics = {}

    def add_metric(self, key: str, value: float):
        self.metrics[key] = value

    def add_batch(self, batch_data: dict):
        for k, v in batch_data.items():
            self.add_metric(k, v)

class CountingRepository:
    def __init__(self, repo: MetricRepository):
        self._repo = repo
        self.write_count = 0

    def add_metric(self, key: str, value: float):
        self.write_count += 1
        self._repo.add_metric(key, value)

    def add_batch(self, batch_data: dict):
        self.write_count += len(batch_data)
        self._repo.add_batch(batch_data)

The fix often involves either re-architecting with composition (e.g., CountingRepository holds a MetricRepository instance) or very carefully designing the inheritance hierarchy to ensure that super() calls lead to non-overlapping responsibilities. Composition isolates concerns and prevents unintended interactions.

How This Fails in Real Systems

An e-commerce order routing agent subclassed a third-party shipping client, overriding validation routines. The base library changed its internal lookup loops to utilize these validated functions, triggering recursive loops and infinite DB query calls that crashed critical order processors during peak trading hours.

Key Takeaway

When building complex behaviors, prefer composition over inheritance to isolate implementation details and avoid unexpected dynamic method dispatch side effects.
Common mistake: Developers using inheritance with method overriding often don't fully anticipate the side effects of calling super() on methods that themselves call other overriden methods, leading to double-counting or unexpected behavior.