Inheritance and composition both let one object reuse another's behavior, but they create very different kinds of coupling. This lesson works through a real refactor ā from a fragile inheritance hierarchy to a flexible composed one ā so the trade-off stops being an abstract slogan and becomes a concrete design skill.
1Why Inheritance Feels Right at First
class FileLogger(Logger): genuinely does save typing at the moment you write it ā format_message is inherited for free, and FileLogger only needs to override the one method that actually differs. This immediate payoff is exactly why inheritance is usually the first tool reached for when one class needs 'most of what another class already does.'
The hidden cost doesn't show up in this first version; it shows up when requirements *grow* in a direction the original hierarchy didn't anticipate. The moment you need logging to go to both the console *and* a file simultaneously, FileLogger and a hypothetical ConsoleLogger don't compose ā you're stuck either writing a new ConsoleAndFileLogger subclass that duplicates logic from both, or reaching for multiple inheritance, which brings its own complexity (method resolution order, diamond problems) for what should be a simple 'do both things' requirement.
Add a requirement for JSON-formatted output on top of that, and the combinatorial nature of the problem becomes undeniable: every independent axis of variation (where output goes, how it's formatted) that gets modeled as a subclass multiplies against every other axis, producing a subclass explosion that grows multiplicatively, not additively, with each new feature.
class Logger:
def format_message(self, msg: str) -> str:
return f"[LOG] {msg}"
def log(self, msg: str) -> None:
print(self.format_message(msg))
class FileLogger(Logger):
def log(self, msg: str) -> None:
with open("app.log", "a") as f:
f.write(self.format_message(msg) + "\n")= 4 subclasses needed, growing multiplicatively
2Composition: Independent Axes, Independently Swappable
The composed Logger fixes the combinatorial problem by recognizing that 'where output goes' and 'how it's formatted' are genuinely *independent* concerns, and modeling each as its own small, swappable object rather than baking both into a single class hierarchy. Logger no longer inherits formatting or output behavior ā it *holds references* to a formatter object and a list of output objects, delegating to them rather than implementing their behavior itself.
Adding a new output destination (say, RemoteOutput, shipping logs to a remote aggregation service) now requires writing exactly one new class implementing the shared write() interface ā Logger itself doesn't change, JsonFormatter doesn't change, no existing Output implementation changes. The same is true in reverse: adding a new formatter never requires touching any output class. Each axis of variation grows independently, additively, instead of multiplying against every other axis.
This is the concrete mechanism behind 'favor composition over inheritance': composition decomposes a problem along its *actual* independent dimensions (what varies together stays together; what varies independently gets separated), while a naive inheritance hierarchy often forces every combination of independent concerns into a single, linear chain of is-a relationships that can't represent 'and' cleanly.
class ConsoleAndFileLogger(Logger):
def log(self, msg):
print(self.format_message(msg))
with open("app.log", "a") as f:
f.write(self.format_message(msg) + "\n")
# Need JSON formatting too? Now it's ConsoleAndFileJsonLogger...
# combinatorial explosion of subclassesZero existing classes touched ā pure addition
3When Inheritance Is Still the Right Choice
None of this means inheritance is a mistake to avoid categorically ā it remains the right tool specifically when there's a genuine, stable is-a relationship *and* the subclass fully honors the parent's behavioral contract (the Liskov Substitution Principle from the previous lesson). A ValidationError subclassing Exception is a clean, appropriate use of inheritance: every ValidationError genuinely *is* an Exception in every meaningful sense, that relationship is stable and unlikely to need 'and' composition later, and Python's exception-handling machinery is itself built around exactly this kind of type hierarchy.
A useful diagnostic question when choosing between the two: 'will I ever need an object that has *both* of these behaviors simultaneously, or need to swap one of these behaviors independently at runtime?' If yes, that's a strong signal toward composition ā inheritance struggles to represent 'has both' or 'swappable at runtime' cleanly, since a class's base classes are fixed at definition time, not reconfigurable per-instance the way composed collaborator objects are.
Many of Python's own standard-library and popular third-party designs lean composition-first for exactly this flexibility: logging.Logger itself (the real standard-library one) is composed of Handler, Formatter, and Filter objects, not a deep class hierarchy ā a strong real-world validation of the pattern this lesson builds by hand.
class Logger:
def __init__(self, formatter, outputs: list):
self.formatter = formatter
self.outputs = outputs
def log(self, msg: str) -> None:
formatted = self.formatter.format(msg)
for output in self.outputs:
output.write(formatted)
logger = Logger(JsonFormatter(), [ConsoleOutput(), FileOutput("app.log")])Genuine, stable is-a ā inheritance is the right call here
4Step-by-Step Breakdown
"Favor composition over inheritance" is easy to say and hard to apply. Let's refactor a real, fragile inheritance hierarchy and feel exactly why the advice exists.
Inheritance looks convenient at first: a FileLogger reusing a Logger's format_message method for free.
The fragility shows up when requirements grow: needing BOTH file and console output means either multiple inheritance chaos or a new subclass per COMBINATION.
Checkpoint: What problem does needing "console AND file AND JSON" logging expose about the inheritance-based design?
- āEvery new combination of features requires a brand new subclass ā a combinatorial explosion
- āInheritance is inherently slower at runtime than composition
Composition fixes this: Logger HAS a formatter and HAS a list of outputs, both swappable independently, with no combinatorial subclassing.
Checkpoint: In the composed Logger, how many classes need to change to add a new output destination (e.g. sending logs to a remote server)?
- āZero existing classes change ā just add one new output class implementing the same interface
- āEvery existing formatter and output class needs to be updated
With composition as your default reach, design patterns are the next layer ā many of the classic patterns exist specifically to structure composition well.
Compose Real Behavior via Injection. Finish Logger.log(): injecting the output object avoids a subclass explosion.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Ask "will this need to combine with something else?" before choosing inheritance
If a class might need to mix and match independent behaviors (output destinations, formats, strategies) rather than fitting one fixed slot in a hierarchy, composition avoids the combinatorial subclass explosion inheritance would produce.
Reserve inheritance for genuine, stable is-a relationships that satisfy LSP
Exception hierarchies, and cases where a subclass is truly a specialized version of its parent with no behavioral surprises, are exactly where inheritance remains simpler and more appropriate than composition.
Frequent Bugs
Extending an inheritance hierarchy to handle a new combination of features (e.g. ConsoleAndFileJsonLogger) instead of recognizing the combinatorial growth as a signal to refactor toward composition.
When a class hierarchy starts needing a new subclass for every combination of independent features, refactor the independent, combinable behaviors into separate composed objects instead of continuing to grow the hierarchy.
Real-World Examples
Refactoring a Payment Processor Hierarchy Into Composed Strategies
A PaymentProcessor class hierarchy has grown CreditCardProcessorWithRetry, PayPalProcessorWithRetry, and CreditCardProcessorWithLogging subclasses ā every new cross-cutting concern (retry, logging) multiplies against every payment method.
class PaymentProcessor:
def __init__(self, gateway, retry_policy, logger):
self.gateway = gateway
self.retry_policy = retry_policy
self.logger = logger
def process(self, amount: float) -> bool:
self.logger.log(f"Processing {amount}")
return self.retry_policy.execute(lambda: self.gateway.charge(amount))
processor = PaymentProcessor(CreditCardGateway(), ExponentialBackoffRetry(), ConsoleLogger())