Composition vs Inheritance: OOP Capstone Project

You've learned classes, inheritance, and design patterns. Now comes the hard part: knowing when to use them. This is where composition vs inheritance stops being theory and becomes the difference between code that scales and code that collapses under its own weight.
Here's the problem: inheritance feels natural. You see "Dog is an Animal" and think, "Perfect, I'll inherit." But six months later, you're drowning in a class hierarchy three levels deep, unable to reuse behavior without creating weird subclasses. You've hit the Liskov Substitution Principle wall, and we're going to blow right through it.
This article is your capstone. We'll refactor a real inheritance disaster into elegant composition, build a plugin system that actually works, and teach you the code smells that scream "you need composition, not inheritance."
Table of Contents
- OOP Design Choices: The Fork in the Road That Matters
- The Inheritance Trap: A Real Example
- Why Inheritance Betrays You
- Why Composition Over Inheritance
- Enter Composition: The Better Way
- The Has-A vs Is-A Decision
- Design Decision Framework
- Mixin-Based Composition: Flexible Behavior Injection
- Refactoring From Inheritance to Composition
- Building a Plugin System with Composition
- Refactoring: From Inheritance to Composition
- Real-World Design Patterns
- Testing OOP Code: The Composition Advantage
- OOP Code Smells: When to Worry
- 1. **God Classes**
- 2. **Deep Hierarchies**
- 3. **Shotgun Surgery**
- 4. **Fragile Base Class**
- 5. **Violation of Liskov Substitution Principle**
- Complete Example: A Real Task Manager
- When Inheritance Is Actually Right
- The Decision Tree
- The Capstone Challenge
- Conclusion
- Summary
OOP Design Choices: The Fork in the Road That Matters
Before we dive into code, let's talk about something most tutorials skip: why this decision matters so much in the first place. Every time you design an object-oriented system, you're standing at a fork. On the left is inheritance, the classic "extend a parent class" approach that every introductory Python course teaches you first. On the right is composition, assembling objects from smaller, independent pieces. Both paths get you to working code. But they lead to dramatically different destinations when your codebase grows.
Here's what nobody tells you early on: the inheritance path gets exponentially harder to walk as your system grows. Each additional layer of inheritance adds cognitive load, debugging complexity, and fragility. You add a feature to a base class and suddenly three subclasses break in subtle ways. You try to reuse behavior from one class in another unrelated class and find yourself creating frankenstein hierarchies just to share a twenty-line method. You write tests and discover you need to mock not just the class under test but its entire ancestry chain.
Composition, by contrast, scales linearly. You add a new behavior by writing a new, isolated class. You reuse that behavior by passing it as an argument. You test it in isolation without touching anything else. The design stays clean because the pieces stay independent.
This capstone project exists because understanding the theory is one thing, but watching a real refactor happen, seeing the before and after, feeling the difference, is what actually changes how you code. We're going to cover the inheritance trap, the has-a versus is-a decision, mixins, plugin systems, refactoring patterns, testing advantages, and the code smells that should send you running toward composition. By the end, you won't just know the rules, you'll understand why they exist, and you'll have the judgment to know when to break them. Let's get into it.
The Inheritance Trap: A Real Example
Let's start with the crime scene. Here's a task manager with inheritance gone wrong:
class Task:
def __init__(self, title, due_date):
self.title = title
self.due_date = due_date
self.completed = False
def mark_complete(self):
self.completed = True
class UrgentTask(Task):
def __init__(self, title, due_date, priority_level):
super().__init__(title, due_date)
self.priority_level = priority_level
def mark_complete(self):
super().mark_complete()
self.notify_manager()
def notify_manager(self):
print(f"Manager notified: {self.title} completed")
class RecurringTask(Task):
def __init__(self, title, due_date, frequency):
super().__init__(title, due_date)
self.frequency = frequency
def mark_complete(self):
super().mark_complete()
self.reschedule()
def reschedule(self):
print(f"Rescheduling {self.title} for {self.frequency}")
class UrgentRecurringTask(UrgentTask, RecurringTask): # š© HERE COMES THE PAIN
passThis code looks harmless at first glance, three levels of inheritance, clean method overrides, proper use of super(). But the moment you introduce UrgentRecurringTask, you've walked into a trap that even experienced Python developers dread.
Feel the horror? UrgentRecurringTask inherits from two classes that both override mark_complete(). Which one gets called? This is the Python Method Resolution Order (MRO) problem, and it's a symptom of deeper disease.
Let's trace what happens:
task = UrgentRecurringTask("Daily standup", "2026-02-25", 3, "daily")
task.mark_complete()The MRO is: UrgentRecurringTask ā UrgentTask ā RecurringTask ā Task. So UrgentTask.mark_complete() runs first. But RecurringTask.mark_complete() never executes unless you explicitly call it, and now you've got hidden bugs. The manager gets notified, but the task never gets rescheduled. That's a silent failure, and silent failures are the worst kind, your code runs without error, but your business logic is broken and you won't find out until a user reports a bug in production.
The Liskov Substitution Principle says: "Subtypes must be substitutable for their supertypes." Here, UrgentRecurringTask breaks that promise. It doesn't behave like either parent in a predictable way.
Why Inheritance Betrays You
Inheritance creates tight coupling. When you inherit, you're saying:
- "I depend on the parent's entire implementation"
- "I promise to work everywhere the parent works"
- "If the parent changes, I might break"
Over time, you end up with:
- God Classes: One base class doing too much
- Fragile Base Class Problem: You can't change the parent without breaking children
- Deep Hierarchies:
AnimalāMammalāCarnivoreāCatāHouseCat(4 levels of indirection!) - Shotgun Surgery: Add one feature, change 5 classes
The real kicker? You're using inheritance for code reuse when composition is cleaner.
Why Composition Over Inheritance
The software engineering community has been converging on a clear answer to this question for decades. The Gang of Four design patterns book, published in 1994 and still required reading for serious developers, literally states "favor object composition over class inheritance" as one of its foundational principles. That's not a suggestion; it's a battle scar from thousands of developers who learned the hard way.
Here's the core reason: inheritance couples you to implementation details, while composition couples you only to interfaces. When you inherit from a class, you're not just borrowing its public API, you're taking on its entire internal structure, its private state, and its evolution over time. Every time the parent class changes, you have to think about every subclass. That mental overhead compounds as your codebase grows.
Composition flips this relationship. Instead of "I am a kind of X," you say "I use X to do Y." Your class holds a reference to a behavior object, delegates to it when needed, and can swap it out for a different implementation at any time, even at runtime. This is the foundation of the Strategy pattern, the Observer pattern, and virtually every modern framework's plugin architecture. When you compose behavior rather than inheriting it, you gain the ability to mix and match capabilities freely, test each piece in isolation, and evolve your system without fear. You also make your code more readable: a class with three injected dependencies tells you exactly what it needs to function, while a class three levels deep in an inheritance hierarchy forces you to read the entire ancestry tree to understand its behavior.
Enter Composition: The Better Way
Composition means: "Objects contain other objects." Instead of asking "what is this?", ask "what does this have?"
Here's the refactored task manager. Notice how the problem of multiple behaviors combining unpredictably simply disappears when each behavior lives in its own dedicated class:
class TaskNotifier:
def notify(self, task_title):
print(f"Manager notified: {task_title} completed")
class TaskRescheduler:
def reschedule(self, task_title, frequency):
print(f"Rescheduling {task_title} for {frequency}")
class Task:
def __init__(self, title, due_date):
self.title = title
self.due_date = due_date
self.completed = False
self.notifier = None
self.rescheduler = None
def mark_complete(self):
self.completed = True
if self.notifier:
self.notifier.notify(self.title)
if self.rescheduler:
self.rescheduler.reschedule(self.title, "default")What changed?
Taskis now the single source of truth- Behavior is plugged in via composition, not inheritance
- There's no weird MRO, no multiple inheritance headaches
- You can mix and match behaviors however you want
Here's how you'd build our problematic task. This is the same functionality as before, but now the code tells you exactly what's happening and why:
notifier = TaskNotifier()
rescheduler = TaskRescheduler()
task = Task("Daily standup", "2026-02-25")
task.notifier = notifier
task.rescheduler = rescheduler
task.mark_complete()
# Output:
# Manager notified: Daily standup completed
# Rescheduling Daily standup for defaultNo inheritance. No MRO. No confusion. And now you can reuse TaskNotifier with any other class that needs it, a Meeting, a Project, a Reminder, without creating a parallel inheritance hierarchy for every new class type.
The Has-A vs Is-A Decision
This is the golden rule: Use inheritance for is-a relationships. Use composition for has-a relationships.
Is-a: Dog IS-A Animal ā inheritance makes sense
Has-a: Dog HAS-A Tail ā composition is correct
But here's the catch: Most relationships are has-a, not is-a.
Let's look at a classic mistake. This one shows up in beginner codebases constantly, and it seems fine until you realize what you've actually modeled:
# ā WRONG: Using inheritance
class Engine:
def start(self):
print("Engine started")
class Car(Engine): # Car IS-A Engine? No!
pass
car = Car()
car.start() # Confusing. Is Car also an Engine?Compare to composition. The second version doesn't just work, it accurately represents reality, which is what good software modeling is supposed to do:
# ā
RIGHT: Using composition
class Engine:
def start(self):
print("Engine started")
class Car:
def __init__(self):
self.engine = Engine()
def start(self):
self.engine.start()
car = Car()
car.start() # Clear. Car delegates to its engine.The second version tells the true story: Cars have engines; they aren't engines. And as a bonus, you can now easily swap in a different engine type, an ElectricEngine, a HybridEngine, a MockEngine for testing, without touching the Car class at all.
Design Decision Framework
When you're faced with a design choice in real code, having a framework to reason through it systematically is far more useful than memorizing rules. Here's the framework we actually use when making these decisions.
Start by asking: Is there a genuine "is a kind of" relationship? Not "could I model it as inheritance" but "is this fundamentally a specialization?" A Truck is a kind of Vehicle, that's a real categorical relationship. A Logger is not a kind of Task, that's just code reuse masquerading as taxonomy. If the answer is genuinely yes, inheritance might be appropriate. If you had to squint to convince yourself, reach for composition.
Next, ask: Will these two classes evolve independently? If the parent class is in a library you don't control, or if the parent and child have different reasons to change, inheritance is dangerous. The moment the parent changes in a way that breaks your subclass, you're stuck. Composition insulates you from that problem entirely.
Then ask: Do I need multiple behaviors combined? The moment you find yourself wanting class Foo(A, B, C), stop. Multiple inheritance is a design smell 95% of the time. Composition handles multiple behaviors cleanly: create separate behavior objects and inject them. Finally, ask: How will I test this? If the honest answer is "I'll need to set up a complex parent class with the right state before I can even test my class," that's a clear signal to use composition. The ability to inject mock behaviors is one of composition's greatest practical advantages.
Mixin-Based Composition: Flexible Behavior Injection
Sometimes you want to add behavior to multiple classes without creating inheritance hierarchies. That's where mixins come in, but used with composition, they're cleaner.
Here's a logging mixin. This is the traditional approach, and while it works, it has subtle problems that only appear at scale:
class LoggingMixin:
def log(self, message):
print(f"[LOG] {message}")
class Task:
def __init__(self, title, due_date):
self.title = title
self.due_date = due_date
self.completed = False
def mark_complete(self):
self.completed = True
class LoggedTask(LoggingMixin, Task): # Old way: mixin inheritance
def mark_complete(self):
self.log(f"Marking {self.title} as complete")
super().mark_complete()The inheritance version works, but it's implicit. Better: explicit composition. With this approach, you can see exactly what dependencies your Task has, and you can swap them out at will:
class Logger:
def log(self, message):
print(f"[LOG] {message}")
class Task:
def __init__(self, title, due_date, logger=None):
self.title = title
self.due_date = due_date
self.completed = False
self.logger = logger
def mark_complete(self):
if self.logger:
self.logger.log(f"Marking {self.title} as complete")
self.completed = True
task = Task("Email report", "2026-02-26", logger=Logger())
task.mark_complete()
# Output: [LOG] Marking Email report as completeWhy is this better?
- The logger is optional (safer)
- You can swap loggers at runtime
Taskdoesn't know about logging implementation details- You can test logging independently
The key insight here is that by making the dependency explicit and injectable, you've created a seam in your code, a place where you can swap implementations without touching the class itself. That seam is invaluable for testing, for extending behavior, and for keeping your codebase flexible as requirements change.
Refactoring From Inheritance to Composition
Real codebases don't give you the luxury of designing everything from scratch. More often, you inherit (pun intended) code that's already deeply tangled in inheritance, and your job is to untangle it safely. This is one of the most valuable skills a mid-level developer can have, and composition makes it possible.
The refactoring process follows a consistent pattern. First, you identify what behavior lives in the parent class that the child actually needs. Second, you extract that behavior into its own standalone class. Third, you inject that behavior into the child class as a constructor argument or attribute. Fourth, you delete the inheritance relationship. The child class now composes the behavior it needs rather than inheriting it. This process can be done incrementally, you don't have to refactor everything at once. You can extract one behavior at a time, verifying tests pass at each step.
A critical thing to watch for during refactoring: classes that inherit from a parent purely to get access to utility methods. This is the most common inheritance abuse pattern. If your UserController inherits from BaseController only because BaseController has a format_response() method, that's not inheritance, that's a lazy import. Extract format_response() into a helper function or a utility class, inject it where needed, and delete the inheritance. Your code will be clearer, your tests will be simpler, and your future self will thank you.
Building a Plugin System with Composition
This is where composition really shines. Let's build a task manager that accepts custom behaviors, plugins, at runtime.
First, define what a plugin is. The use of Protocol here is key, it means you're defining a structural contract rather than a class hierarchy:
from typing import Protocol
class TaskPlugin(Protocol):
"""Any object with this method can be a task plugin."""
def on_task_complete(self, task: "Task") -> None:
"""Called when a task is marked complete."""
...
class Task:
def __init__(self, title, due_date):
self.title = title
self.due_date = due_date
self.completed = False
self.plugins = []
def add_plugin(self, plugin: TaskPlugin):
self.plugins.append(plugin)
def mark_complete(self):
self.completed = True
for plugin in self.plugins:
plugin.on_task_complete(self)What's Protocol? It's structural typing, the plugin doesn't need to inherit from anything. It just needs to have on_task_complete(). That's the power of composition: you don't couple to base classes. Any object that implements the right method signature becomes a valid plugin automatically. This is sometimes called "duck typing with documentation."
Now write plugins. Each one is completely independent, you can develop, test, and deploy them separately:
class NotificationPlugin:
def on_task_complete(self, task):
print(f"ā
{task.title} is done!")
class AnalyticsPlugin:
def on_task_complete(self, task):
print(f"š Task completed: {task.title}")
class SlackPlugin:
def on_task_complete(self, task):
print(f"š¤ Posting to Slack: {task.title}")Use them together. Notice that you're combining three completely independent behaviors without any of them knowing about the others:
task = Task("Deploy app", "2026-02-25")
task.add_plugin(NotificationPlugin())
task.add_plugin(AnalyticsPlugin())
task.add_plugin(SlackPlugin())
task.mark_complete()
# Output:
# ā
Deploy app is done!
# š Task completed: Deploy app
# š¤ Posting to Slack: Deploy appThis is the magic. You add three independent plugins without touching Task. Want to remove analytics? Delete one line. Add email? Drop in EmailPlugin. This scales to hundreds of plugins. And critically, each plugin is independently testable, you can verify that SlackPlugin posts correctly without having to instantiate a full Task with all its other dependencies configured.
Refactoring: From Inheritance to Composition
Let's walk through a real refactor. Here's a messy inheritance chain. This example comes up constantly in animal simulations, game development, and any domain where entities have overlapping capabilities:
class Animal:
def __init__(self, name, speed):
self.name = name
self.speed = speed
def move(self):
print(f"{self.name} moves at {self.speed} mph")
class Swimmer(Animal):
def move(self):
print(f"{self.name} swims")
class Flyer(Animal):
def move(self):
print(f"{self.name} flies")
class Duck(Swimmer, Flyer): # š© Multiple inheritance
passWhich move() runs? Swimmer.move() (due to MRO). But Duck can both swim and fly, so this design sucks. More importantly, there's no way to express "Donald can do both" without writing custom code in Duck to explicitly call both parent methods, and at that point you've defeated the purpose of inheritance entirely.
Refactor to composition:
class Movement:
def execute(self, name):
raise NotImplementedError
class Swimming(Movement):
def execute(self, name):
print(f"{name} swims")
class Flying(Movement):
def execute(self, name):
print(f"{name} flies")
class Animal:
def __init__(self, name, movements):
self.name = name
self.movements = movements # list of Movement objects
def move(self):
for movement in self.movements:
movement.execute(self.name)
duck = Animal("Donald", [Swimming(), Flying()])
duck.move()
# Output:
# Donald swims
# Donald fliesThe code now reads exactly like the requirements: Donald can swim and fly. No MRO lookup needed, no hidden behavior, no surprises.
Compare the two:
| Aspect | Inheritance | Composition |
|---|---|---|
| Code | Inheritance chain | Simple composition |
| Flexibility | Fixed (MRO decides) | Dynamic (add behaviors at runtime) |
| Reusability | Tied to class hierarchy | Standalone components |
| Testing | Mock parent classes | Mock individual behaviors |
| Complexity | Grows exponentially | Linear |
The composition version is cleaner, testable, and infinitely more flexible. And if you need to add Running behavior to a Cheetah later, you just create a Running(Movement) class and pass it in, no changes to Animal, no new subclasses, no MRO analysis required.
Real-World Design Patterns
The patterns we've been exploring aren't theoretical constructs, they're the foundation of virtually every major Python framework and library you'll encounter in production work. Understanding how they manifest in real code helps you recognize when to reach for them in your own projects.
Django's middleware system is pure composition. Each middleware class receives a request, does something with it, and passes it to the next middleware in the chain. The framework doesn't care what class hierarchy your middleware belongs to, it just needs the right methods. You can add, remove, or reorder middleware with a single configuration change. SQLAlchemy's event system uses the observer pattern we built in the plugin example: attach listeners to database events, and they fire automatically when those events occur, completely decoupled from the core ORM logic.
FastAPI's dependency injection system is perhaps the most elegant example in the modern Python ecosystem. You declare what your endpoint needs, and FastAPI figures out how to provide it. Your business logic never directly instantiates its own dependencies, they're composed in from the outside. This makes every endpoint trivially testable with mock dependencies. The pytest fixture system works the same way. Celery's task pipeline uses composition to chain tasks: each task is an independent unit that can be combined with other tasks using the pipe operator, producing complex workflows without any class being aware of what comes before or after it. These aren't coincidences, they're the result of experienced engineers choosing composition over inheritance and discovering that the result is more extensible, more testable, and more maintainable at scale.
Testing OOP Code: The Composition Advantage
Composition makes testing so much easier. Here's why. With inheritance, to test a child class you often have to understand and configure the entire parent class correctly first. With composition, you just swap in a simple mock:
class Task:
def __init__(self, title, notifier):
self.title = title
self.notifier = notifier
def mark_complete(self):
self.notifier.notify(self.title)
# Test with a mock notifier
class MockNotifier:
def __init__(self):
self.called = False
self.last_title = None
def notify(self, title):
self.called = True
self.last_title = title
def test_task_notifies_on_complete():
mock = MockNotifier()
task = Task("Email", mock)
task.mark_complete()
assert mock.called
assert mock.last_title == "Email"
test_task_notifies_on_complete()
print("ā
Test passed")Why is this better?
- No need to mock a parent class
- The notifier is explicit, not hidden in
super() - You're testing the interaction directly
- Swap real and fake implementations easily
With inheritance, you'd need to mock the entire parent chain. Gross. And if the parent class makes network calls, database queries, or file system accesses, you'd need to mock all of those too, which means your unit test is now testing three classes instead of one. Composition keeps unit tests focused on exactly one unit of behavior at a time, which is the whole point.
OOP Code Smells: When to Worry
Here are the red flags that scream "refactor to composition":
1. God Classes
A single class doing everything:
# ā Bad
class TaskManager:
def create_task(self): pass
def delete_task(self): pass
def send_email(self): pass
def save_to_database(self): pass
def log_to_file(self): pass
def calculate_analytics(self): pass
def generate_report(self): passFix: Decompose into smaller classes, use composition.
# ā
Good
class TaskManager:
def __init__(self, database, emailer, logger):
self.database = database
self.emailer = emailer
self.logger = logger2. Deep Hierarchies
More than 3 levels of inheritance:
# ā Bad
Animal ā Mammal ā Carnivore ā Cat ā HouseCat ā PersianCatYou lose track of where behavior comes from. Use composition instead.
3. Shotgun Surgery
One change breaks multiple classes:
# ā Bad
class Animal:
def move(self): pass
class Dog(Animal):
def move(self): pass # Must update
class Cat(Animal):
def move(self): pass # Must update
class Bird(Animal):
def move(self): pass # Must updateWith composition, you update the behavior once.
4. Fragile Base Class
Changing a parent breaks children:
class Parent:
def helper(self):
return "value"
class Child(Parent):
def do_something(self):
return self.helper() # Breaks if parent.helper() changes
class AnotherChild(Parent):
def do_something_else(self):
return self.helper() # Also breaksComposition isolates changes.
5. Violation of Liskov Substitution Principle
A subclass breaks the parent's contract:
# ā Bad
class Bird:
def fly(self):
pass
class Penguin(Bird): # Penguins can't fly!
def fly(self):
raise NotImplementedError("Penguins don't fly")This is OOP malpractice. Instead:
# ā
Good
class Bird:
pass
class FlyingBird(Bird):
def fly(self):
pass
class Penguin(Bird):
def swim(self):
passOr better, composition:
class Bird:
def __init__(self, can_fly=False):
self.can_fly = can_fly
def perform_movement(self):
if self.can_fly:
self.fly()
else:
self.walk()Complete Example: A Real Task Manager
Let's build something production-ready. This combines everything: composition, plugins, no inheritance disasters. Every design decision here is deliberate, the Protocol for structural typing, the observer list for extensibility, the clean separation between Task state management and observer notification logic:
from datetime import datetime
from typing import Protocol, List
# Plugins
class TaskObserver(Protocol):
def update(self, task: "Task", event: str) -> None:
...
class LogObserver:
def update(self, task, event):
print(f"[{datetime.now()}] {event}: {task.title}")
class NotificationObserver:
def __init__(self, webhook_url):
self.webhook_url = webhook_url
def update(self, task, event):
print(f"POST {self.webhook_url} ā {task.title}: {event}")
# Core
class Task:
def __init__(self, title, due_date):
self.title = title
self.due_date = due_date
self.completed = False
self.observers = []
def attach(self, observer: TaskObserver):
self.observers.append(observer)
def detach(self, observer: TaskObserver):
self.observers.remove(observer)
def notify_observers(self, event: str):
for observer in self.observers:
observer.update(self, event)
def mark_complete(self):
self.completed = True
self.notify_observers("COMPLETED")
def mark_incomplete(self):
self.completed = False
self.notify_observers("REOPENED")
class TaskManager:
def __init__(self):
self.tasks = []
def add_task(self, task: Task):
self.tasks.append(task)
def get_completed_count(self):
return sum(1 for t in self.tasks if t.completed)
# Usage
manager = TaskManager()
task1 = Task("Deploy API", "2026-02-26")
task1.attach(LogObserver())
task1.attach(NotificationObserver("https://api.example.com/webhooks"))
task2 = Task("Write docs", "2026-02-27")
task2.attach(LogObserver())
manager.add_task(task1)
manager.add_task(task2)
task1.mark_complete()
# Output:
# [2026-02-25 10:30:45.123456] COMPLETED: Deploy API
# POST https://api.example.com/webhooks ā Deploy API: COMPLETED
task2.mark_complete()
# Output:
# [2026-02-25 10:30:46.654321] COMPLETED: Write docs
print(f"Completed: {manager.get_completed_count()}/2")
# Output: Completed: 2/2What just happened?
Taskis simple: it stores data and notifies observers- Observers are pluggable: swap, add, remove at runtime
TaskManagerorchestrates everything- No inheritance hierarchies, no god classes, no MRO confusion
- This scales to thousands of tasks and observers
You could add a DatabaseObserver that persists task completions, an AuditObserver that logs changes for compliance, or a MetricsObserver that feeds a monitoring dashboard, all without changing a single line of Task or TaskManager. That's the payoff for choosing composition.
When Inheritance Is Actually Right
Not all inheritance is evil. Use it when:
-
True is-a relationships exist:
pythonclass EmailAddress: # Simple class pass class WorkEmail(EmailAddress): # Specialization pass -
Abstract base classes enforce contracts:
pythonfrom abc import ABC, abstractmethod class DataStore(ABC): @abstractmethod def save(self, data): pass class DatabaseStore(DataStore): def save(self, data): # Database-specific implementation pass class FileStore(DataStore): def save(self, data): # File-specific implementation pass -
You're implementing a framework:
- Django models inherit from
Model - Flask views inherit from
MethodView - These are tools, not your domain code
- Django models inherit from
For your own code? Default to composition. You'll write less code, test faster, and sleep better.
The Decision Tree
Do I need to extend behavior?
āā Is it a true "is-a" relationship?
ā āā YES ā Use inheritance
ā āā NO ā Use composition
āā Does the parent change often?
ā āā YES ā Use composition
ā āā NO ā Maybe inheritance is okay
āā Will I have multiple implementations?
āā YES ā Use composition or abstract base
āā NO ā Could go either way
Default answer: Composition. You'll rarely regret it.
The Capstone Challenge
Here's what you should be able to do now:
- Identify inheritance smells in existing code
- Refactor inheritance to composition without losing functionality
- Design plugin systems using protocols and composition
- Write testable OOP code that doesn't require mock inheritance chains
- Choose between inheritance and composition confidently
You've completed the OOP trilogy:
- Classes and objects (fundamentals)
- Design patterns (strategy, observer, factory)
- Composition vs inheritance (mastery) ā You are here
From here, you're ready for:
- File I/O and data persistence
- Building real applications
- Understanding frameworks (Django, FastAPI)
- Contributing to open source
The code you write matters. The choices you make about inheritance vs composition ripple through your codebase for years. Choose wisely.
Conclusion
We've covered a lot of ground in this capstone, and it's worth pulling the threads together. The key insight isn't just that composition is better than inheritance, it's understanding why, at a deep enough level that you can make the call confidently in your own code without consulting a flowchart.
Inheritance is not wrong. It's a tool that solves a specific problem: expressing genuine categorical relationships and enforcing behavioral contracts through abstract base classes. Use it for those things and it serves you well. The problem is that developers reach for it as a code reuse mechanism, as a way to share behavior across classes that don't share a meaningful "is a kind of" relationship, and that's where the pain begins. Every deep inheritance hierarchy you've ever cursed at, every MRO problem you've ever debugged, every fragile base class that broke a dozen subclasses when someone changed a utility method, these all trace back to that single misapplication.
Composition solves the code reuse problem cleanly, without the coupling costs. When you extract behavior into dedicated classes and inject them as dependencies, you get modularity, testability, and flexibility for free. Your classes become honest about what they need to function. Your tests become simple because you can swap in mocks at the boundary. Your system becomes extensible because adding new behavior means adding new classes, not modifying existing ones.
This is the foundation of every modern architecture pattern, microservices, plugins, middleware, dependency injection. When you read Django source code, FastAPI internals, or any well-maintained open source library, you'll see these patterns everywhere. Now you understand why they're there and how to apply them in your own work.
Go build something. When you find yourself about to write class Foo(Bar, Baz), stop, take a breath, and ask whether Foo can just have a bar and a baz instead. Nine times out of ten, the answer is yes, and your future self will be grateful for the choice.
Summary
- Inheritance is for is-a relationships. Dog is an Animal.
- Composition is for has-a relationships. Dog has a Tail.
- Most relationships are has-a. Default to composition.
- Liskov Substitution Principle protects you. Subclasses must be substitutable.
- Code smells warn you: God classes, deep hierarchies, shotgun surgery.
- Plugins via composition scale infinitely. Observers, protocols, and behavior injection.
- Composition is more testable. Mock individual behaviors, not entire hierarchies.
- Refactor inheritance to composition gradually. Extract behaviors into separate classes.
You now have the tools to build OOP systems that scale. The question isn't "should I use inheritance?" anymore, it's "what composition pattern best solves this problem?"
Go forth and write unmaintainable inheritance chains with confidence that you know better.