The Design Decision Framework
This lesson gives you a one-question checklist for deciding how to organize your classes. You do not need to memorize theory. Just ask: "Is B a specialized version of A?" If yes, use inheritance. If no, use composition. When in doubt, use composition.
Python supports ABC (Abstract Base Classes) with @abstractmethod for explicit interface contracts. This lesson also previews Protocol from the typing module (covered fully in Chapter 61), which provides structural subtyping -- duck typing with type safety.
In Lesson 3, James built NoteCollection with composition: it contains a list rather than inheriting from one. He built TagManager the same way, wrapping a dict. By the end, he could state the difference clearly: inheritance for specialization, composition for containment. But then he asked the question that matters: "When I face a real design decision next week, how do I choose?"
Emma walks to the whiteboard and draws a box with one question inside it. Two arrows leave the box: one labeled YES, one labeled NO.
"One question," she says. "Two paths. That is the entire framework."
James has seen this pattern before. When he ran vendor evaluations at the warehouse, he had a one-page checklist for deciding whether to hire a specialist or contract a service. A specialist (like a full-time electrician) does everything a general maintenance worker does, plus electrical work: that is inheritance. A contracted service (like a cleaning company) provides a specific capability you plug in when needed: that is composition. One question on the checklist determined which path: "Does this person need to do everything our general staff does, plus more?" Yes meant hire. No meant contract.
"Same idea," Emma says. "One question, clear answer."
The One Question
Here is the framework:
Does B share A's interface AND is B truly a specialized version of A?
YES -> inheritance. B IS-A specialized A.
NO -> composition. B HAS-A reference to A.
When in doubt -> composition.
The "when in doubt" default is deliberate. Lesson 2 showed that inheritance creates coupling: changing the parent can break every child. Composition does not have that problem. If you are unsure, the safer choice is composition. You can always refactor to inheritance later if the is-a relationship becomes clear.
Applying the Framework: Five SmartNotes Decisions
The framework becomes concrete when you apply it. Here are five class relationships from SmartNotes. For each one, ask the question and follow the arrow.
1. TextNote and Note
The question: Does TextNote share Note's interface AND is TextNote truly a specialized version of Note?
Answer: YES. TextNote has title, body, author, tags, word_count, summarize, add_tag, remove_tag, has_tag. It shares Note's full interface. A TextNote can substitute for a Note anywhere. It adds specialization (the [TEXT] prefix in summarize). This is inheritance.
class TextNote(Note):
def summarize(self) -> str:
tag_str = ", ".join(self.tags) if self.tags else "no tags"
return f"[TEXT] {self.title} ({self.word_count} words, {tag_str})"
2. NoteCollection and list
The question: Does NoteCollection share list's interface AND is NoteCollection truly a specialized version of list?
Answer: NO. A list has reverse, insert, pop, sort, clear, and dozens of other methods. NoteCollection should not expose any of them. A NoteCollection is not a general-purpose list. It is a container that uses a list internally. This is composition.
class NoteCollection:
def __init__(self) -> None:
self._notes: list[Note] = []
def add(self, note: Note) -> None:
self._notes.append(note)
3. SearchIndex and dict
The question: Does SearchIndex share dict's interface AND is SearchIndex truly a specialized version of dict?
Answer: NO. A dict has popitem, setdefault, update, and direct key access. SearchIndex has specific behavior: indexing notes by words in their body, searching by keyword. It uses a dict internally for storage, but its interface is completely different. This is composition.
class SearchIndex:
def __init__(self) -> None:
self._index: dict[str, list[Note]] = {}
def index(self, note: Note) -> None:
for word in note.body.lower().split():
self._index.setdefault(word, []).append(note)
4. CodeNote and Note
The question: Does CodeNote share Note's interface AND is CodeNote truly a specialized version of Note?
Answer: YES. CodeNote has everything Note has, plus a language attribute and a specialized summarize. A CodeNote can substitute for a Note anywhere. It is a specialized version with additional context. This is inheritance.
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
5. SmartNotesApp and NoteCollection
The question: Does SmartNotesApp share NoteCollection's interface AND is SmartNotesApp truly a specialized version of NoteCollection?
Answer: NO. SmartNotesApp coordinates multiple components: a NoteCollection, a TagManager, possibly a SearchIndex. It is not a specialized collection. It HAS a collection. This is composition.
class SmartNotesApp:
def __init__(self) -> None:
self.notes = NoteCollection()
self.tags = TagManager()
self.search = SearchIndex()
The Pattern
| Relationship | Question Answer | Decision | Reasoning |
|---|---|---|---|
| TextNote / Note | YES | Inheritance | Shares full interface, specializes |
| NoteCollection / list | NO | Composition | Does not want list's interface |
| SearchIndex / dict | NO | Composition | Different interface entirely |
| CodeNote / Note | YES | Inheritance | Shares full interface, adds field |
| SmartNotesApp / NoteCollection | NO | Composition | Coordinates, does not specialize |
Enforcing the Contract: ABC and @abstractmethod
When you decide on inheritance, you often want to guarantee that every subclass implements certain methods. Right now, nothing stops someone from creating a subclass of Note that forgets to override summarize. The base Note.summarize() still runs, and the output is technically valid but missing the type-specific formatting you expect.
Python's abc module lets you make the contract explicit:
from abc import ABC, abstractmethod
class Note(ABC):
def __init__(self, title: str, body: str, author: str = "Anonymous") -> None:
if not title.strip():
raise ValueError("Title cannot be empty")
self.title = title
self.body = body
self.author = author
self.tags: list[str] = []
@property
def word_count(self) -> int:
return len(self.body.split())
@abstractmethod
def summarize(self) -> str:
"""Each note type must define its own summary format."""
...
def add_tag(self, tag: str) -> None:
if tag not in self.tags:
self.tags.append(tag)
def remove_tag(self, tag: str) -> None:
if tag not in self.tags:
raise ValueError(f"Tag '{tag}' not found")
self.tags.remove(tag)
def has_tag(self, tag: str) -> bool:
return tag in self.tags
Two changes: Note inherits from ABC, and summarize is decorated with @abstractmethod. Now try creating a Note directly:
note = Note(title="Test", body="Hello")
Output:
TypeError: Can't instantiate abstract class Note with abstract method summarize
Python refuses to create an instance of Note because summarize has no implementation. Now try a subclass that forgets to implement it:
class BrokenNote(Note):
pass
broken = BrokenNote(title="Test", body="Hello")
Output:
TypeError: Can't instantiate abstract class BrokenNote with abstract method summarize
The same error. Python enforces the contract at instantiation time, not at class definition time. You can define BrokenNote without error. The error appears when you try to create an instance.
A subclass that implements summarize works normally:
class TextNote(Note):
def summarize(self) -> str:
tag_str = ", ".join(self.tags) if self.tags else "no tags"
return f"[TEXT] {self.title} ({self.word_count} words, {tag_str})"
text = TextNote(title="Meeting", body="Discussed Q3")
print(text.summarize())
Output:
[TEXT] Meeting (2 words, no tags)
ABC and @abstractmethod are guardrails for inheritance hierarchies. They document intent ("every Note subclass must have summarize") and enforce it at runtime.
Protocol Preview
There is another approach to defining what an object must be able to do: Protocol from the typing module. Instead of requiring explicit inheritance, a Protocol checks whether an object has the right methods, regardless of its class hierarchy. This is called structural subtyping, or "duck typing with type checking."
A detailed treatment of Protocols belongs in Chapter 61, where you will build Protocol-based designs from scratch. For now, the key distinction: ABC says "you must inherit from me." Protocol says "you must have these methods, I do not care where they came from."
Testing Design Decisions
When you test a class, focus on what the object can do (its interface), not how it stores data internally (its implementation).
def test_text_note_summarize():
note = TextNote(title="Meeting", body="Discussed Q3 targets")
result = note.summarize()
assert "[TEXT]" in result
assert "Meeting" in result
assert "3 words" in result
This test does not check note.title directly. It does not inspect note.tags as a list. It tests the output of summarize, which is the interface the rest of your code depends on. If you later change how TextNote stores its title (maybe you add a Title value object), this test still passes as long as summarize produces the right output.
Interface tests are design-resilient. Implementation tests are design-fragile.
PRIMM-AI+ Practice: Classify and Build
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Given these four class relationships, classify each as inheritance or composition using the one-question framework. Write your answer and confidence (1-5) for each:
ElectricCarandCar(ElectricCar has everything Car has, plus a battery)Playlistandlist(Playlist holds songs but should not exposereverseorinsert)AdminUserandUser(AdminUser has everything User has, plus permissions)ShoppingCartanddict(ShoppingCart maps items to quantities but should not exposepopitem)
Check your predictions
- Inheritance. ElectricCar shares Car's full interface and is a specialized version. It adds battery attributes. Confidence target: 4-5.
- Composition. Playlist is not a general-purpose list. It should not expose
reverse,insert, orclear. It contains a list internally. Confidence target: 4-5. - Inheritance. AdminUser shares User's full interface and adds permissions. An AdminUser can substitute for a User anywhere. Confidence target: 4-5.
- Composition. ShoppingCart is not a general-purpose dict. It should not expose
popitem,setdefault, or direct key deletion. It uses a dict internally. Confidence target: 4-5.
Run
Press Shift+Tab to exit Plan Mode.
Compare your classifications to the framework's answers above. If any of yours differ, reread the one question and trace through it again for that relationship.
Investigate
In Claude Code, type:
I classified ElectricCar/Car as inheritance and Playlist/list
as composition. But what about Playlist and Song? Is a Playlist
a specialized Song? Does it contain Songs? Walk me through the
framework for this relationship.
The AI will clarify a relationship that could trip you up: Playlist is not a specialized Song (inheritance does not apply), and it is not a Song with extra features. A Playlist HAS Songs. This is composition, but the "what is A and what is B" matters: you are comparing Playlist to Song, not Playlist to list.
Modify
Take the SearchIndex class from Lesson 3 (composition-based, wraps a dict). Refactor it to inherit from dict instead:
class SearchIndex(dict):
def index(self, note: Note) -> None:
for word in note.body.lower().split():
self.setdefault(word, []).append(note)
def search(self, keyword: str) -> list[Note]:
return self.get(keyword, [])
Run it. It works. Now try calling search_index.popitem(). It also works, and it removes an arbitrary keyword from your index. Ask yourself: is that behavior you want exposed? The composition version prevents it. The inheritance version allows it.
Make [Mastery Gate]
You are given a new domain: a music library with Song, Playlist, and Artist.
Design the class relationships using the framework. For each pair, write:
- The one question applied to this pair
- Your answer (YES or NO)
- The decision (inheritance or composition)
- One sentence of reasoning
Then implement the classes. Each Song has a title, artist name, and duration in seconds. A Playlist contains songs and exposes add_song, total_duration, and song_count. An Artist has a name and a list of songs.
Write 3 tests:
- Adding a song to a playlist increases
song_count total_durationsums all song durationsPlaylistdoes not have areversemethod (composition check)
Run pyright and pytest. Both must pass.
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: Evaluate My Decisions
Here are my classifications for 5 SmartNotes relationships:
1. TextNote/Note -> inheritance
2. NoteCollection/list -> composition
3. SearchIndex/dict -> composition
4. CodeNote/Note -> inheritance
5. SmartNotesApp/NoteCollection -> composition
For each one, tell me if I am right or wrong. If wrong,
explain where my reasoning failed. If right, give me one
edge case where the decision might change.
What you're learning: You are stress-testing your own decisions against the AI's analysis. The edge cases reveal that design decisions are context-dependent. A NoteCollection might justify inheriting from list if the app genuinely needs all list operations. The framework gives you a starting point; the edge cases build judgment.
Prompt 2: ABC or Protocol?
I have a Note class with @abstractmethod on summarize().
When should I use ABC with @abstractmethod vs Protocol
from typing? Give me one scenario where ABC is better
and one where Protocol is better. Use simple examples.
What you're learning: ABC enforces an explicit contract through inheritance. Protocol enforces a structural contract without inheritance. Knowing when to use each is a design skill you will develop fully in Chapter 61. This prompt gives you the conceptual preview so you arrive at that chapter with the right question already forming.
James pulls out his phone and opens a note. "Vendor evaluation checklist," he says. "One question: does this vendor do everything our internal team does, plus something extra? Yes means hire full-time. No means contract the service. I have used this for three years. Works every time."
"The design framework is the same idea," Emma says. "One question, two paths. And you already noticed the important part: 'when in doubt, contract the service.' Same reason: contracts are easier to cancel than full-time hires. Composition is easier to refactor than inheritance."
She pauses. "I still second-guess myself on borderline cases. Last month I went with inheritance for a report formatter, refactored to composition a week later, then back to inheritance when I realized the formatter really was a specialized version of the base document. The framework gives you a starting point, not a guarantee. The real skill is recognizing when your initial decision was wrong and being willing to change it."
"So I have inheritance for specialization, composition for containment, the framework for deciding, and ABC for enforcement. What is left?"
"Building it. You have the tools. Now build the SmartNotes hierarchy from scratch. Lesson 5."