The Inheritance Trap
The fragile base class problem means that changing a parent class can break all its children. This lesson shows you what goes wrong and teaches you the warning signs so you can avoid building fragile hierarchies.
Python's MRO (Method Resolution Order) handles the diamond problem with C3 linearization. This lesson covers MRO briefly but focuses on the practical question: how deep is too deep? The answer depends on whether you can modify the parent without reading every child.
In Lesson 1, James built three subclasses: TextNote(Note), CodeNote(Note), and LinkNote(Note). Each one calls super().__init__() to reuse the parent's validation. Twelve tests pass. The hierarchy works.
Now Emma gives him a task. "SmartNotes needs timestamps. Add a created_at parameter to Note.__init__."
James opens Note and adds it:
from datetime import datetime
class Note:
def __init__(self, title: str, body: str, author: str = "Anonymous",
created_at: datetime = None) -> None:
if not title.strip():
raise ValueError("Title cannot be empty")
self.title = title
self.body = body
self.author = author
self.created_at = created_at or datetime.now()
self.tags: list[str] = []
He runs the tests.
FAILED test_text_note - TypeError: Note.__init__() got an unexpected keyword argument
FAILED test_code_note - TypeError: Note.__init__() got an unexpected keyword argument
FAILED test_link_note - TypeError: Note.__init__() got an unexpected keyword argument
Output:
3 failed, 0 passed
One change to the parent. Three crashes in the children.
The Fragile Base Class Problem
Every child class calls super().__init__() with a specific set of arguments. When the parent's __init__ signature changes, every super().__init__() call that does not match the new signature breaks.
Here is what CodeNote.__init__ looks like from Lesson 1:
class CodeNote(Note):
def __init__(self, title: str, body: str, language: str,
author: str = "Anonymous") -> None:
super().__init__(title=title, body=body, author=author)
self.language = language
That super().__init__() call was written when Note.__init__ accepted title, body, and author. Now Note.__init__ also accepts created_at. The call still works here because created_at has a default value, but imagine if created_at were required, or if James renamed body to content. Every child breaks.
This is the fragile base class problem: the parent (base class) is fragile because changing it can break children you forgot existed. The more children you have, the more things break. The deeper your hierarchy, the harder it is to predict what will break.
James frowns. "It is like changing company policy at headquarters. Every branch office has to adapt. If you have 50 branches, one policy change creates 50 updates."
"Exactly," Emma says. "And if those branch offices have sub-offices, the cascade goes even deeper."
Deep Hierarchies: The Coupling Cascade
One level of inheritance is manageable. Two levels start to get complicated. Three or more levels create a cascade where changes at the top ripple all the way down.
class Note:
def __init__(self, title: str, body: str, author: str = "Anonymous") -> None:
self.title = title
self.body = body
self.author = author
class FormattedNote(Note):
def __init__(self, title: str, body: str, fmt: str = "plain",
author: str = "Anonymous") -> None:
super().__init__(title=title, body=body, author=author)
self.fmt = fmt
class StyledNote(FormattedNote):
def __init__(self, title: str, body: str, fmt: str = "plain",
style: str = "default", author: str = "Anonymous") -> None:
super().__init__(title=title, body=body, fmt=fmt, author=author)
self.style = style
class HighlightedNote(StyledNote):
def __init__(self, title: str, body: str, fmt: str = "plain",
style: str = "default", color: str = "yellow",
author: str = "Anonymous") -> None:
super().__init__(title=title, body=body, fmt=fmt, style=style,
author=author)
self.color = color
Four levels deep. Each __init__ must pass every parameter from every ancestor up the chain. Now add created_at to Note.__init__. You must update FormattedNote, then StyledNote, then HighlightedNote. Three files. Three super().__init__() calls. Three chances to introduce a bug.
Run HighlightedNote("Test", "body"):
Output:
HighlightedNote object created
Now add a required priority: int parameter to Note.__init__. Run HighlightedNote("Test", "body") again:
Output:
TypeError: Note.__init__() missing 1 required positional argument: 'priority'
The crash happened in Note.__init__, but the call chain went HighlightedNote -> StyledNote -> FormattedNote -> Note. Finding where to fix it means reading every class in the chain.
James thinks about his warehouse. "We once reorganized the management structure from four levels to two. Regional manager, then store manager. That was it. Decisions moved faster because there were fewer layers to cascade through."
"Same idea here," Emma says. "Flat hierarchies adapt faster. If you are inheriting more than two levels deep, ask yourself: does each level represent a genuine specialization, or am I just stacking code reuse?"
The Diamond Problem (Brief)
What happens when a class inherits from two parents that share a grandparent?
class Note:
def save(self):
print("Note.save")
class CloudNote(Note):
def save(self):
print("CloudNote.save")
super().save()
class EncryptedNote(Note):
def save(self):
print("EncryptedNote.save")
super().save()
class SecureCloudNote(CloudNote, EncryptedNote):
pass
SecureCloudNote inherits from both CloudNote and EncryptedNote, which both inherit from Note. This creates a diamond shape:
Note
/ \
CloudNote EncryptedNote
\ /
SecureCloudNote
Which save runs? Python uses the Method Resolution Order (MRO) to decide:
print(SecureCloudNote.__mro__)
Output:
(SecureCloudNote, CloudNote, EncryptedNote, Note, object)
Python searches left to right: SecureCloudNote first, then CloudNote, then EncryptedNote, then Note. So CloudNote.save runs first. Each super().save() call follows the MRO, not the class's direct parent.
note = SecureCloudNote()
note.save()
Output:
CloudNote.save
EncryptedNote.save
Note.save
Python resolved the diamond. But the complexity is a warning sign. When you have to check __mro__ to understand which method runs, the design is fighting you.
When Inheritance Is Wrong
Inheritance means "is-a." A TextNote is a Note. That relationship is genuine: anywhere your code expects a Note, a TextNote works.
But developers sometimes use inheritance for code reuse, not for genuine type relationships:
# WRONG: NoteCollection is NOT a list
class NoteCollection(list):
def summarize_all(self):
return [note.summarize() for note in self]
A NoteCollection is not a list. It uses a list internally to store notes. Inheriting from list gives NoteCollection dozens of methods it should not have: sort, reverse, insert, pop. A user could call collection.append(42) and add an integer to a collection that should only hold notes.
The fix is simple: contain the list instead of inheriting from it. But that is composition, which is Lesson 3's topic.
Another common mistake:
# WRONG: Logger is NOT a FileHandler
class Logger(FileHandler):
def log(self, message):
self.write(f"[LOG] {message}")
A Logger is not a FileHandler. A logger uses a file handler to write output. Inheriting from FileHandler couples the logger to files. What if you want to log to a database? To a network socket? You would need a separate DatabaseLogger(DatabaseHandler) class that duplicates the logging logic.
The test: "Is X truly a specialized version of Y, or does X merely use Y?"
- TextNote is-a Note: correct. Inheritance fits.
- NoteCollection is-a list: wrong. It uses a list.
- Logger is-a FileHandler: wrong. It uses a file handler.
PRIMM-AI+ Practice: Hierarchy Breakage
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Here is a 3-level hierarchy:
class Animal:
def __init__(self, name: str, legs: int) -> None:
self.name = name
self.legs = legs
class Pet(Animal):
def __init__(self, name: str, legs: int, owner: str) -> None:
super().__init__(name=name, legs=legs)
self.owner = owner
class Dog(Pet):
def __init__(self, name: str, owner: str) -> None:
super().__init__(name=name, legs=4, owner=owner)
self.tricks: list[str] = []
Now Animal.__init__ changes to require a new parameter species: str with no default value.
Answer these and write your confidence (1-5):
- Which classes break? Just
Pet, justDog, or both? - Why does
Dogbreak even thoughDognever callsAnimal.__init__directly? - How many files do you need to edit to fix this?
Check your predictions
- Both break.
Petcallssuper().__init__(name, legs)which no longer matchesAnimal.__init__(name, legs, species).Dogcallssuper().__init__(name, legs=4, owner)which callsPet.__init__, which callsAnimal.__init__, which fails. Confidence target: 3-5. - The cascade.
DogcallsPet.__init__, which callsAnimal.__init__. The breakage propagates through the chain.Dogis coupled toAnimaleven though it never mentionsAnimalby name. Confidence target: 3-5. - Two files. You must update
Pet.__init__to accept and passspecies, then updateDog.__init__to passspeciesthroughPet.__init__. Confidence target: 4-5.
Run
Press Shift+Tab to exit Plan Mode.
Create a file called hierarchy_break.py. Paste the three classes above. Add species: str as a required parameter to Animal.__init__ (after name, before legs). Run Dog("Rex", "James") and observe the crash.
Investigate
In Claude Code, type:
/investigate Why does Dog crash when I only changed Animal.__init__?
Show me the full call chain from Dog.__init__ to Animal.__init__
and explain where the missing argument causes the failure.
Compare the AI's explanation to your prediction. The chain is Dog.__init__ -> Pet.__init__ -> Animal.__init__. The break happens at the last link, but every link in the chain needs updating.
Modify
Fix the hierarchy: update Pet.__init__ and Dog.__init__ to pass species through the chain. Run Dog("Rex", "James") again and verify it works.
Then ask yourself: was this fix easy with 3 classes? Would it scale to 10 child classes across 5 files? That is the fragile base class problem in practice.
Make [Mastery Gate]
You are given this hierarchy:
class Tool:
def __init__(self, name: str) -> None:
self.name = name
class PowerTool(Tool):
def __init__(self, name: str, watts: int) -> None:
super().__init__(name=name)
self.watts = watts
class CordlessTool(PowerTool):
def __init__(self, name: str, watts: int, battery_ah: float) -> None:
super().__init__(name=name, watts=watts)
self.battery_ah = battery_ah
class CordlessDrill(CordlessTool):
def __init__(self, name: str, watts: int, battery_ah: float,
chuck_size: str) -> None:
super().__init__(name=name, watts=watts, battery_ah=battery_ah)
self.chuck_size = chuck_size
Identify:
- Which classes use inheritance for genuine specialization (is-a)?
- Which level of depth is unnecessary? Why?
- If
Tool.__init__adds a requiredbrand: strparameter, how many classes break?
Write your answers. Then run pyright on the file to confirm no type errors exist in the current version. Modify Tool to add brand, observe the cascade, and verify your prediction.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Show Me a Bad Hierarchy
Generate a 4-level class hierarchy where each level adds one
attribute. Then explain three specific problems this hierarchy
would cause in a real project. Show me what breaks if the
top-level class changes its __init__ signature.
What you're learning: Seeing a complete bad hierarchy helps you recognize the pattern in your own code. The AI generates the kind of hierarchy that feels natural to build but becomes painful to maintain. Your job is to spot the warning signs.
Prompt 2: Is This Really Is-A?
Evaluate these three class relationships. For each one, tell me
if inheritance is correct or if the class should use composition
instead. Explain your reasoning.
1. class ShoppingCart(list)
2. class ElectricCar(Car)
3. class DatabaseLogger(DatabaseConnection)
What you're learning: The is-a test is the core decision tool for inheritance. The AI evaluates each relationship, but you are building the judgment to make these calls yourself. One of these is correct inheritance; two are not.
James looks at his Note hierarchy: three subclasses, one level deep. "The warehouse org chart used to have four management layers," he says. "CEO, VP, regional manager, store manager. When headquarters changed a policy, it took weeks to reach the stores. We flattened it to two layers: CEO and store manager. Policies moved in hours, not weeks. Same idea with classes. Flat hierarchies adapt faster."
Emma nods. "I am honestly not sure where the right depth limit is. I say two levels, but some frameworks use three or four and manage it well. Django's class-based views go three levels deep, and they work. The real test is not a number -- it is this: can you change the parent without reading every child first? If you have to open ten files to understand one change, the coupling is too tight, regardless of how many levels you have."
"So if inheritance can trap you, what is the alternative? How do you share behavior without coupling parent and child?"
"That is composition. A NoteCollection does not inherit from list -- it contains a list. You get the behavior you need without inheriting the behavior you do not. Next lesson."