Type something to search...
Composition vs Inheritance in Python: Designing Flexible Classes

Composition vs Inheritance in Python: Designing Flexible Classes

Inheritance is usually the first tool people learn for reusing code in classes, so it tends to become the tool for everything. Need an email notifier? Subclass Notifier. Need an urgent one? Subclass EmailNotifier. Six months later you have a tree of classes where changing one method in the base breaks three subclasses you forgot existed.

"Favour composition over inheritance" is old advice, but it's easier to repeat than to apply. This post makes it concrete in Python: what goes wrong with inheritance, how composition fixes it, how to delegate cleanly, where mixins fit, and the cases where inheritance really is the right answer.

If you need a refresher on the mechanics of subclassing, super(), and method resolution order first, see Inheritance and super() in Python. This post is about design: deciding which relationship to use.

Two Ways to Reuse Code

  • Inheritance says a class is a kind of another class. A NotFoundError is an AppError. The subclass gets the parent's methods and can override them.
  • Composition says a class has a component that does part of the work. A Notifier has a Channel. The outer class calls methods on the component it holds.

Both let you reuse code. The difference is how tightly the pieces are coupled. A subclass is bound to its parent's internals; a class that holds a component only depends on the component's public methods.

Problem 1: The Fragile Base Class

Here's a subclass of list that counts how many items have been added:

class CountingList(list):
    def __init__(self, *args):
        super().__init__(*args)
        self.added = 0

    def append(self, item):
        self.added += 1
        super().append(item)


items = CountingList()
items.append("a")
items.extend(["b", "c"])
items += ["d"]
items.insert(0, "z")
print(items, items.added)
['z', 'a', 'b', 'c', 'd'] 1

Five items were added and the counter says one. extend(), +=, and insert() don't go through append(); they're separate methods implemented in C. To make the subclass correct you'd have to know and override every method that adds an item, and hope a future Python version doesn't add another.

This is the fragile base class problem. A subclass depends on implementation details of its parent (which methods call which) that the parent never promised to keep. The same thing happens with your own classes: refactor the base, and subclasses that overrode a method the base no longer calls silently stop working.

The Composition Version

Instead of being a list, hold one, and expose only the operations you actually support:

from collections.abc import Iterable, Iterator


class CountingBag:
    def __init__(self) -> None:
        self._items: list[str] = []
        self.added = 0

    def add(self, item: str) -> None:
        self._items.append(item)
        self.added += 1

    def add_many(self, items: Iterable[str]) -> None:
        for item in items:
            self.add(item)

    def __iter__(self) -> Iterator[str]:
        return iter(self._items)

    def __len__(self) -> int:
        return len(self._items)


bag = CountingBag()
bag.add("a")
bag.add_many(["b", "c"])
print(list(bag), len(bag), bag.added)
['a', 'b', 'c'] 3 3

There's no way to add an item that skips the counter, because the only ways in are the ones you wrote. The internal list is an implementation detail. You could swap it for a deque tomorrow and no caller would know.

You do have to write more methods by hand. That's the trade: a bit more code in exchange for a smaller surface you fully control. If you genuinely need the full list or dict interface, the classes in collections.abc (such as MutableSequence) generate most methods from a few you write, and all of them route through yours. The abc module post shows that pattern.

Problem 2: The Class Explosion

Now a design problem rather than a correctness one. You're sending notifications, which vary along two independent axes: the channel (email, SMS) and the formatting (plain, urgent). With inheritance:

class Notifier:
    def notify(self, to: str, message: str) -> None:
        self.send(to, self.format(message))

    def format(self, message: str) -> str:
        return message

    def send(self, to: str, message: str) -> None:
        raise NotImplementedError


class EmailNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"email to {to}: {message}")


class SmsNotifier(Notifier):
    def send(self, to: str, message: str) -> None:
        print(f"sms to {to}: {message[:20]}")


class UrgentEmailNotifier(EmailNotifier):
    def format(self, message: str) -> str:
        return f"[URGENT] {message.upper()}"


class UrgentSmsNotifier(SmsNotifier):
    def format(self, message: str) -> str:
        return f"[URGENT] {message.upper()}"

The urgent formatting is duplicated already. Add a Slack channel and you need SlackNotifier and UrgentSlackNotifier. Add a Markdown formatter and the count multiplies again: channels times formatters. Every combination needs a class, and you can't change a notifier's channel after creating it.

Composing the Pieces

Split each axis into its own small object, and let Notifier hold one of each:

# notifications.py
from dataclasses import dataclass, field
from typing import Protocol


class Channel(Protocol):
    def send(self, to: str, message: str) -> None: ...


class Formatter(Protocol):
    def format(self, message: str) -> str: ...


class EmailChannel:
    def send(self, to: str, message: str) -> None:
        print(f"email to {to}: {message}")


class SmsChannel:
    def send(self, to: str, message: str) -> None:
        print(f"sms to {to}: {message[:20]}")


class PlainFormatter:
    def format(self, message: str) -> str:
        return message


class UrgentFormatter:
    def format(self, message: str) -> str:
        return f"[URGENT] {message.upper()}"


@dataclass
class Notifier:
    channel: Channel
    formatter: Formatter = field(default_factory=PlainFormatter)

    def notify(self, to: str, message: str) -> None:
        self.channel.send(to, self.formatter.format(message))


Notifier(EmailChannel()).notify("ada@example.com", "Your report is ready")
Notifier(SmsChannel(), UrgentFormatter()).notify("+15550100", "Server is down")
email to ada@example.com: Your report is ready
sms to +15550100: [URGENT] SERVER IS D

What changed:

  • Channel and Formatter are Protocols. They describe the methods a component needs, without requiring any base class. EmailChannel never imports Channel; it just has a matching send() method, and type checkers verify that.
  • Notifier is a small dataclass that holds its components and coordinates them.
  • Adding a Slack channel is one new class. Adding a Markdown formatter is one new class. The count grows as channels plus formatters, not channels times formatters.

The components are also ordinary attributes, so behaviour can change at runtime:

n = Notifier(EmailChannel())
n.formatter = UrgentFormatter()
n.notify("ada", "swap at runtime")
# email to ada: [URGENT] SWAP AT RUNTIME

Combining Components

Because a component only needs the right method, you can build components out of other components. A channel that fans out to several channels is itself a channel:

class MultiChannel:
    def __init__(self, *channels: Channel) -> None:
        self.channels = channels

    def send(self, to: str, message: str) -> None:
        for channel in self.channels:
            channel.send(to, message)


Notifier(MultiChannel(EmailChannel(), SmsChannel())).notify("ada", "Deploy finished")
email to ada: Deploy finished
sms to ada: Deploy finished

With the inheritance version, "send by email and SMS" would have needed yet another subclass, probably with multiple inheritance.

Composition Makes Testing Easier

Composed objects take their collaborators as arguments, which is exactly what you need to test them in isolation. No patching, no network:

class FakeChannel:
    def __init__(self) -> None:
        self.sent: list[tuple[str, str]] = []

    def send(self, to: str, message: str) -> None:
        self.sent.append((to, message))


fake = FakeChannel()
Notifier(fake, UrgentFormatter()).notify("ops", "disk full")
assert fake.sent == [("ops", "[URGENT] DISK FULL")]

With the subclass design, testing UrgentEmailNotifier means intercepting the real email-sending code inside the class, typically with unittest.mock.patch. Passing dependencies in (often called dependency injection, though in Python it's usually just "constructor arguments") avoids that entirely.

Delegation: Wrapping an Object

Sometimes you want to add one behaviour to an existing object and pass everything else through untouched. Writing a forwarding method for every attribute would be tedious. Python's __getattr__ handles the rest for you: it's called only when normal attribute lookup fails.

import io
import time


class TimedFile:
    """Wraps a file object and records how long writes take."""

    def __init__(self, f) -> None:
        self._f = f
        self.write_seconds = 0.0

    def write(self, data: str) -> int:
        start = time.perf_counter()
        try:
            return self._f.write(data)
        finally:
            self.write_seconds += time.perf_counter() - start

    def __getattr__(self, name: str):
        return getattr(self._f, name)


buf = io.StringIO()
tf = TimedFile(buf)
tf.write("hello ")
tf.write("world")
print(tf.getvalue(), tf.closed)  # hello world False

write() is defined on TimedFile, so it's found normally. getvalue and closed aren't, so __getattr__ forwards them to the wrapped file. This works for any file-like object, not just StringIO, which a subclass couldn't do.

Two caveats. Special methods like __len__ or __iter__ are looked up on the type, not the instance, so __getattr__ won't forward them; define those explicitly if you need them. And blanket forwarding hides what the wrapper supports, so prefer explicit methods when the interface is small.

Mixins: Inheritance for Small, Independent Behaviours

There's a middle ground that Python uses a lot: the mixin. A mixin is a small class that adds one capability, has no state of its own, and isn't meant to be instantiated alone.

import json


class JsonMixin:
    def to_json(self) -> str:
        return json.dumps(vars(self), sort_keys=True)


class ReprMixin:
    def __repr__(self) -> str:
        fields = ", ".join(f"{k}={v!r}" for k, v in vars(self).items())
        return f"{type(self).__name__}({fields})"


class User(JsonMixin, ReprMixin):
    def __init__(self, name: str, email: str) -> None:
        self.name = name
        self.email = email


u = User("Ada", "ada@example.com")
print(u)
print(u.to_json())
User(name='Ada', email='ada@example.com')
{"email": "ada@example.com", "name": "Ada"}

Mixins work well when the behaviour is orthogonal to what the class is, needs nothing but the object's public attributes, and doesn't override methods from other parents. Django's class-based views (LoginRequiredMixin) and the standard library's socketserver.ThreadingMixIn are well-known examples.

Mixins stop being a good idea when they carry state, depend on each other, or need a specific order in the base class list to work. At that point you've rebuilt the fragile hierarchy, just horizontally. Switch to composition.

When Inheritance Is the Right Call

Composition isn't always better. Inheritance is the right tool when:

  • There's a genuine "is a" relationship and the subclass is substitutable. Anywhere the parent is accepted, the child works without surprises. Exception hierarchies are the textbook case:

    class AppError(Exception):
        """Base class for this app's errors."""
    
    
    class NotFoundError(AppError):
        def __init__(self, resource: str, id: int) -> None:
            super().__init__(f"{resource} {id} not found")
            self.resource = resource
            self.id = id
    

    Callers can catch AppError and handle every error the app raises. That's exactly what inheritance is for.

  • A framework is designed around it. Django models, unittest.TestCase, argparse.Action, Enum, and collections.abc ABCs all expect you to subclass. The framework documents which methods to override, so the base class is promising a stable contract.

  • The hierarchy is shallow and you own both sides. One level of subclassing under an abstract base you control is easy to reason about.

A Quick Decision Guide

QuestionLeans inheritanceLeans composition
Is the new class substitutable everywhere the parent is used?YesNo, or only partly
Do you need every method of the parent?YesNo, only a few
Does the behaviour vary along more than one axis?NoYes
Does it need to change at runtime?NoYes
Do you own and control the parent class?YesNo (built-ins, third-party)
Will tests need to replace a piece of it?RarelyOften

If most answers land on the right, compose. If you're subclassing a built-in like list or dict to tweak one method, that's almost always a sign to compose instead. If you really do need the full interface, subclass collections.abc.MutableSequence or MutableMapping: their mixin methods (append, extend, +=, update, setdefault) are all built on the handful of methods you write, so an override can't be bypassed. collections.UserDict routes update() through __setitem__ too, but UserList.extend() does not go through append(), so don't assume the User* classes fix the problem across the board.

Conclusion

Inheritance couples a subclass to its parent's internals; composition couples a class only to its components' public methods. That's why composed designs survive refactoring better, grow additively instead of multiplicatively, and are easier to test.

In practice: describe the pieces you need with small Protocols, pass components in through the constructor, use __getattr__ delegation sparingly for wrappers, and keep mixins small and stateless. Save inheritance for true "is a" relationships, exception hierarchies, and frameworks built around subclassing, and keep those hierarchies shallow.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading