When should you use composition instead of inheritance in Python, and what are the design signals for each?
Inheritance models an 'is-a' relationship and is appropriate when a subclass is genuinely a specialisation of the parent. Composition models a 'has-a' relationship and is preferred when you want to reuse behaviour without coupling to a class hierarchy — it is more flexible, easier to test, and avoids the fragile base-class problem.
How to think about it
The classic framing is is-a vs has-a. A Dog is an Animal — inheritance fits. A Car has an Engine — composition fits. The harder part is recognising when inheritance is being abused, which is much more common in interview code review scenarios.
What’s really being tested
The interviewer wants to see that you understand the Liskov Substitution Principle (can I swap a subclass anywhere its parent is expected, without surprises?) and the fragile base-class problem (what happens when the parent changes?). Composition sidesteps both issues.
Step 1 — The fragile base-class problem
When you override a method in a subclass, you’re betting that the parent’s other methods will never call your override in a surprising way. That bet gets riskier as the class grows.
Step 2 — Composition decouples the pieces
Instead of inheriting behaviour, inject it. The class has a collaborator rather than being a specialisation. Swapping the collaborator requires zero changes to the outer class.
Step 3 — Dependency injection makes testing trivial
Because the collaborator is passed in, tests can inject a stub or mock without subclassing anything.
When inheritance IS the right choice
- The subclass is a genuine subtype — the Liskov Substitution Principle holds: anywhere a
Baseis expected, aSubworks without surprises. - You need polymorphic dispatch enforced by an ABC.
- The hierarchy is shallow (one or two levels). Deep hierarchies almost always signal a composition opportunity.
Mixing both — ABCs + composition
from abc import ABC, abstractmethod
class Formatter(ABC):
@abstractmethod
def format(self, record: dict) -> str: ...
class JSONFormatter(Formatter):
def format(self, record: dict) -> str:
import json
return json.dumps(record)
class Handler(ABC):
def __init__(self, formatter: Formatter) -> None:
self._fmt = formatter # composed
@abstractmethod
def emit(self, record: dict) -> None: ...
Inheritance defines the what (the interface); composition wires the how (the implementation) together at runtime.