Skip to main content

The Design Decision Framework

If you're new to programming

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.

If you've coded before

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

RelationshipQuestion AnswerDecisionReasoning
TextNote / NoteYESInheritanceShares full interface, specializes
NoteCollection / listNOCompositionDoes not want list's interface
SearchIndex / dictNOCompositionDifferent interface entirely
CodeNote / NoteYESInheritanceShares full interface, adds field
SmartNotesApp / NoteCollectionNOCompositionCoordinates, 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:

  1. ElectricCar and Car (ElectricCar has everything Car has, plus a battery)
  2. Playlist and list (Playlist holds songs but should not expose reverse or insert)
  3. AdminUser and User (AdminUser has everything User has, plus permissions)
  4. ShoppingCart and dict (ShoppingCart maps items to quantities but should not expose popitem)
Check your predictions
  1. Inheritance. ElectricCar shares Car's full interface and is a specialized version. It adds battery attributes. Confidence target: 4-5.
  2. Composition. Playlist is not a general-purpose list. It should not expose reverse, insert, or clear. It contains a list internally. Confidence target: 4-5.
  3. Inheritance. AdminUser shares User's full interface and adds permissions. An AdminUser can substitute for a User anywhere. Confidence target: 4-5.
  4. 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:

  1. The one question applied to this pair
  2. Your answer (YES or NO)
  3. The decision (inheritance or composition)
  4. 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:

  1. Adding a song to a playlist increases song_count
  2. total_duration sums all song durations
  3. Playlist does not have a reverse method (composition check)

Run pyright and pytest. Both must pass.


Try With AI

Opening Claude Code

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."