Every OOP tutorial teaches inheritance. Few teach when not to use it.
The Inheritance Trap
It starts innocently:
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof"
class Cat(Animal):
def speak(self):
return "Meow"Clean hierarchy. Then requirements change:
class RobotDog(Dog): # Can't bark, but is it a Dog?
def speak(self):
return "Beep"
class FlyingCat(Cat): # Cats don't fly...
def fly(self):
return "Whoosh"Now you have RobotDog that inherits Dog behaviors it doesn't need, and FlyingCat mixing concerns. The hierarchy becomes a constraint, not a help.
What Composition Looks Like
from dataclasses import dataclass
from typing import Protocol
class Speaker(Protocol):
def speak(self) -> str: ...
class Flyer(Protocol):
def fly(self) -> str: ...
@dataclass
class Barker:
def speak(self) -> str:
return "Woof"
@dataclass
class Meower:
def speak(self) -> str:
return "Meow"
@dataclass
class Beeper:
def speak(self) -> str:
return "Beep"
@dataclass
class Wings:
def fly(self) -> str:
return "Whoosh"
@dataclass
class Dog:
voice: Speaker = field(default_factory=Barker)
def speak(self) -> str:
return self.voice.speak()
@dataclass
class RobotDog:
voice: Speaker = field(default_factory=Beeper)
def speak(self) -> str:
return self.voice.speak()
@dataclass
class FlyingCat:
voice: Speaker = field(default_factory=Meower)
wings: Flyer = field(default_factory=Wings)
def speak(self) -> str:
return self.voice.speak()
def fly(self) -> str:
return self.wings.fly()Mix and match behaviors without hierarchies.
When Inheritance Works
Inheritance isn't always wrong. It works when:
True "is-a" relationships exist:
class HTTPError(Exception):
pass
class NotFoundError(HTTPError):
status_code = 404Exceptions are a classic case. A NotFoundError genuinely is an HTTPError.
You're extending a framework:
class MyTestCase(unittest.TestCase):
def setUp(self):
self.client = TestClient()The framework expects inheritance. Fight it and you'll suffer.
The hierarchy is stable: If the base class hasn't changed in years and won't change, inheritance is safe. Standard library classes often qualify.
When Composition Wins
Behaviors vary independently:
# Bad: explosion of subclasses
class EmailNotifier(Notifier): ...
class SMSNotifier(Notifier): ...
class EmailAndSMSNotifier(Notifier): ... # Uh oh
# Good: compose behaviors
@dataclass
class NotificationService:
channels: list[NotificationChannel]
def notify(self, message: str):
for channel in self.channels:
channel.send(message)You need runtime flexibility:
# Swap implementations without subclassing
service = PaymentService(
processor=StripeProcessor() if prod else MockProcessor()
)Testing is important: Composed dependencies are trivially mockable. Inherited behavior often isn't.
The Practical Test
Before creating a subclass, ask:
- Is this a true "is-a" relationship?
- Will the parent class change?
- Am I inheriting behavior I don't want?
- Could I achieve this with a parameter instead?
If any answer is concerning, try composition first.
Python-Specific Patterns
Protocols over abstract base classes:
from typing import Protocol
class Repository(Protocol):
def save(self, item: Item) -> None: ...
def get(self, id: str) -> Item | None: ...
# Any class with these methods works—no inheritance neededDependency injection:
@dataclass
class UserService:
repo: Repository
mailer: Mailer
def create_user(self, data: dict) -> User:
user = User(**data)
self.repo.save(user)
self.mailer.send_welcome(user)
return userMixins when you must:
class TimestampMixin:
created_at: datetime
updated_at: datetime
class User(TimestampMixin, BaseModel):
name: strMixins are inheritance, but narrow and focused. Use sparingly.
The Mental Shift
Inheritance: "What is this thing?" Composition: "What does this thing do?"
Think in behaviors, not taxonomies. Your code will be more flexible and easier to test.