June 27, 2025
Python OOP Composition Software Design

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
  1. OOP Design Choices: The Fork in the Road That Matters
  2. The Inheritance Trap: A Real Example
  3. Why Inheritance Betrays You
  4. Why Composition Over Inheritance
  5. Enter Composition: The Better Way
  6. The Has-A vs Is-A Decision
  7. Design Decision Framework
  8. Mixin-Based Composition: Flexible Behavior Injection
  9. Refactoring From Inheritance to Composition
  10. Building a Plugin System with Composition
  11. Refactoring: From Inheritance to Composition
  12. Real-World Design Patterns
  13. Testing OOP Code: The Composition Advantage
  14. OOP Code Smells: When to Worry
  15. 1. **God Classes**
  16. 2. **Deep Hierarchies**
  17. 3. **Shotgun Surgery**
  18. 4. **Fragile Base Class**
  19. 5. **Violation of Liskov Substitution Principle**
  20. Complete Example: A Real Task Manager
  21. When Inheritance Is Actually Right
  22. The Decision Tree
  23. The Capstone Challenge
  24. Conclusion
  25. 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:

python
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
    pass

This 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:

python
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:

  1. God Classes: One base class doing too much
  2. Fragile Base Class Problem: You can't change the parent without breaking children
  3. Deep Hierarchies: Animal → Mammal → Carnivore → Cat → HouseCat (4 levels of indirection!)
  4. 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:

python
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?

  • Task is 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:

python
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 default

No 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:

python
# āŒ 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:

python
# āœ… 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:

python
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:

python
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 complete

Why is this better?

  • The logger is optional (safer)
  • You can swap loggers at runtime
  • Task doesn'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:

python
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:

python
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:

python
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 app

This 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:

python
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
    pass

Which 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:

python
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 flies

The code now reads exactly like the requirements: Donald can swim and fly. No MRO lookup needed, no hidden behavior, no surprises.

Compare the two:

AspectInheritanceComposition
CodeInheritance chainSimple composition
FlexibilityFixed (MRO decides)Dynamic (add behaviors at runtime)
ReusabilityTied to class hierarchyStandalone components
TestingMock parent classesMock individual behaviors
ComplexityGrows exponentiallyLinear

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:

python
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:

python
# āŒ 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): pass

Fix: Decompose into smaller classes, use composition.

python
# āœ… Good
class TaskManager:
    def __init__(self, database, emailer, logger):
        self.database = database
        self.emailer = emailer
        self.logger = logger

2. Deep Hierarchies

More than 3 levels of inheritance:

python
# āŒ Bad
Animal → Mammal → Carnivore → Cat → HouseCat → PersianCat

You lose track of where behavior comes from. Use composition instead.

3. Shotgun Surgery

One change breaks multiple classes:

python
# āŒ 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 update

With composition, you update the behavior once.

4. Fragile Base Class

Changing a parent breaks children:

python
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 breaks

Composition isolates changes.

5. Violation of Liskov Substitution Principle

A subclass breaks the parent's contract:

python
# āŒ 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:

python
# āœ… Good
class Bird:
    pass
 
class FlyingBird(Bird):
    def fly(self):
        pass
 
class Penguin(Bird):
    def swim(self):
        pass

Or better, composition:

python
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:

python
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/2

What just happened?

  • Task is simple: it stores data and notifies observers
  • Observers are pluggable: swap, add, remove at runtime
  • TaskManager orchestrates 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:

  1. True is-a relationships exist:

    python
    class EmailAddress:  # Simple class
        pass
     
    class WorkEmail(EmailAddress):  # Specialization
        pass
  2. Abstract base classes enforce contracts:

    python
    from 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
  3. You're implementing a framework:

    • Django models inherit from Model
    • Flask views inherit from MethodView
    • These are tools, not your domain code

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:

  1. Identify inheritance smells in existing code
  2. Refactor inheritance to composition without losing functionality
  3. Design plugin systems using protocols and composition
  4. Write testable OOP code that doesn't require mock inheritance chains
  5. Choose between inheritance and composition confidently

You've completed the OOP trilogy:

  1. Classes and objects (fundamentals)
  2. Design patterns (strategy, observer, factory)
  3. 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.

Need help implementing this?

We build automation systems like this for clients every day.

Discuss Your Project