Skip to main content

Composition

In Lesson 2, Emma showed James what happens when inheritance goes wrong: fragile base classes, deep hierarchies, and classes that inherit methods they should not have. She ended with a specific example: class NoteCollection(list) inherits dozens of unwanted methods. Her closing line: "A NoteCollection does not inherit from list -- it contains a list."

James opens a new file and starts writing:

class NoteCollection(list):
def summarize_all(self):
return [note.summarize() for note in self]

Emma stops him. "Does a NoteCollection IS-A list? Can someone call .reverse() on it? .insert() at position 3? .clear() and delete everything? Those are list methods. Do you want all of them?"

James stares at the screen. A list has over 30 methods. Most of them make no sense for a collection of notes. Someone could call collection.sort() and rearrange notes by memory address. They could call collection.extend([42, "hello"]) and add garbage.

"I want three methods," James says. "Add a note. Search by tag. Count how many notes I have."

"Then do not inherit 30 methods to get 3. Contain the list. Expose only what you need."


If you're new to programming

Composition means one object CONTAINS another object as a part, instead of BEING a type of it. A NoteCollection HAS a list inside it (like a box holding items) rather than BEING a list (like a bigger box that is still a box). You write wrapper methods that use the internal object, but outsiders never touch it directly.

If you've coded before

Composition models a has-a relationship: NoteCollection has a list[Note]. The internal object is a private implementation detail. The containing class exposes a controlled interface through delegation methods. This avoids tight coupling to the contained object's full API and makes the container independently testable.


The Problem: Inheriting Too Much

Create a file called bad_collection.py. Write the inheritance-based version:

class NoteCollection(list):
"""A collection that IS a list -- inherits everything."""

def summarize_all(self):
return [note.summarize() for note in self]

Now test what outsiders can do:

collection = NoteCollection()
collection.append("not a note") # No error -- list accepts anything
collection.insert(0, 42) # Integers in a note collection
collection.reverse() # Why would you reverse notes?
collection.sort() # Sort notes by... what exactly?
print(len(collection))

Output:

2

Two items in a "note collection," neither of which is a Note. The list parent does not know about Note objects. It accepts anything. Every one of its 30+ methods is available, and most of them are dangerous or meaningless for your domain.


The Fix: Contain, Don't Inherit

Create a file called note_collection.py. This time, the list is an internal attribute:

class NoteCollection:
"""A collection that HAS a list -- controls its own interface."""

def __init__(self) -> None:
self._notes: list[Note] = []

def add(self, note: Note) -> None:
self._notes.append(note)

def count(self) -> int:
return len(self._notes)

def search_by_tag(self, tag: str) -> list[Note]:
return [note for note in self._notes if note.has_tag(tag)]

def summarize_all(self) -> list[str]:
return [note.summarize() for note in self._notes]

Test it:

collection = NoteCollection()
note = Note(title="Meeting", body="Discussed Q3 targets")
collection.add(note)
note.add_tag("work")
print(collection.count())
print(collection.search_by_tag("work"))

Output:

1
[<Note: Meeting>]

Now try calling a list method directly:

collection.reverse()

Output:

AttributeError: 'NoteCollection' object has no attribute 'reverse'

reverse does not exist on NoteCollection because you never wrote it. The internal _notes list has reverse, but outsiders cannot reach it. You control the interface.


How Delegation Works

Each method on NoteCollection delegates to the internal list:

NoteCollection methodDelegates toWhat it controls
add(note)self._notes.append(note)Only Note objects enter the collection
count()len(self._notes)Read-only access to size
search_by_tag(tag)list comprehension on self._notesFiltered, domain-specific query
summarize_all()list comprehension on self._notesBulk operation on notes only

The delegation pattern is simple: your method calls the internal object's method. The key insight is what you leave out. No reverse. No sort. No insert. No extend. No clear. Each omitted method is a conscious decision to not expose behavior that does not belong in your domain.

James nods. "It is like my warehouse. The warehouse HAS shelves, forklifts, and workers. I would not say the warehouse IS a building with extra features. It is a warehouse that CONTAINS shelves, an inventory system, and staff. Each piece does its own job. The warehouse decides how they work together."

"Your warehouse-has-shelves framing is cleaner than my usual car-has-engine example," Emma says. "Mine always leads to arguments about whether a car IS a vehicle. Yours avoids that entirely because nobody confuses a warehouse with a shelf."


The Has-A Test

The decision between inheritance and composition comes down to one question:

"Does X HAVE a Y, or IS X a kind of Y?"

QuestionAnswerPattern
Does a NoteCollection HAVE notes?YesComposition
IS a NoteCollection a list?NoNot inheritance
Does a CodeNote HAVE a Note?NoNot composition
IS a CodeNote a kind of Note?YesInheritance
Does a SmartNotesApp HAVE a NoteCollection?YesComposition
IS a SmartNotesApp a kind of NoteCollection?NoNot inheritance

When the answer is "has-a," contain the object. When the answer is "is-a," inherit from the parent. When the answer is unclear, composition is almost always safer because it creates less coupling.


Multiple Components: Building SmartNotesApp

A real application is not one class. It is several components working together. SmartNotesApp needs notes, a way to search them, and a way to manage tags. Each component is a separate class:

class TagManager:
"""Tracks tag usage counts across all notes."""

def __init__(self) -> None:
self._tag_counts: dict[str, int] = {}

def record(self, tag: str) -> None:
self._tag_counts[tag] = self._tag_counts.get(tag, 0) + 1

def get_count(self, tag: str) -> int:
return self._tag_counts.get(tag, 0)

def most_popular(self) -> str | None:
if not self._tag_counts:
return None
return max(self._tag_counts, key=self._tag_counts.get)

TagManager contains a dict[str, int]. It does not inherit from dict. You cannot call self._tag_counts.popitem() from outside. You cannot call tag_manager.clear(). The only operations exposed are record, get_count, and most_popular.

Now assemble the application:

class SmartNotesApp:
"""The full application, built from independent components."""

def __init__(self) -> None:
self.notes = NoteCollection()
self.tags = TagManager()

def add_note(self, note: Note) -> None:
self.notes.add(note)
for tag in note.tags:
self.tags.record(tag)

def search(self, tag: str) -> list[Note]:
return self.notes.search_by_tag(tag)

def popular_tag(self) -> str | None:
return self.tags.most_popular()

SmartNotesApp has a NoteCollection and a TagManager. It is not a NoteCollection. It is not a TagManager. It coordinates them. Each component can be tested, replaced, or modified without touching the others.

app = SmartNotesApp()
meeting = Note(title="Meeting", body="Q3 targets")
meeting.add_tag("work")
meeting.add_tag("urgent")
app.add_note(meeting)

code = CodeNote(title="Sort", body="def sort(arr):", language="python")
code.add_tag("work")
app.add_note(code)

print(app.notes.count())
print(app.popular_tag())
print(len(app.search("work")))

Output:

2
work
2

Two notes in the collection. "work" is the most popular tag with 2 uses. Searching for "work" returns both notes.


Why Composition Wins

ConcernInheritanceComposition
Interface controlInherits all parent methodsExposes only what you write
CouplingChild tightly coupled to parentComponents loosely coupled
Parent changesCan break all children (fragile)Internal; wrapper methods stay stable
TestingMust test through parent interfaceTest each component independently
Replacing a componentRequires refactoring hierarchySwap the contained object

Composition is not "better" in every case. Inheritance is correct when the child genuinely specializes the parent (TextNote is a Note). But when you are assembling an application from parts, composition keeps each part independent and replaceable.


PRIMM-AI+ Practice: TagManager Delegation

Predict [AI-FREE]

Press Shift+Tab to enter Plan Mode before predicting.

Here is the TagManager from above:

class TagManager:
def __init__(self) -> None:
self._tag_counts: dict[str, int] = {}

def record(self, tag: str) -> None:
self._tag_counts[tag] = self._tag_counts.get(tag, 0) + 1

def get_count(self, tag: str) -> int:
return self._tag_counts.get(tag, 0)

Answer these questions and write your confidence (1-5):

  1. Can someone call tag_manager.popitem() to remove a tag? Why or why not?
  2. Can someone call tag_manager._tag_counts.popitem() directly? What convention prevents this?
  3. What would happen if TagManager inherited from dict instead? What unwanted methods would be exposed?
Check your predictions
  1. No. TagManager does not have a popitem method. It only exposes record and get_count. The dict is internal. Confidence target: 4-5.
  2. Technically yes, but the underscore prefix _tag_counts signals "private, do not touch." Python does not enforce this, but the convention tells other developers the attribute is an implementation detail. Confidence target: 3-5.
  3. All dict methods would be exposed: pop, popitem, clear, update, setdefault, and more. Someone could call tag_manager.clear() and wipe all tag data, or tag_manager[tag] = -1 and set invalid counts. Confidence target: 4-5.

Run

Press Shift+Tab to exit Plan Mode.

Create a file called tag_manager.py. Paste the TagManager class. Try calling a dict method on the TagManager instance:

tm = TagManager()
tm.record("python")
tm.record("python")
tm.record("testing")
print(tm.get_count("python"))
tm.popitem() # This should fail

Output:

2
AttributeError: 'TagManager' object has no attribute 'popitem'

Verify that delegation hides the dict's interface. The only way to interact with tag data is through record and get_count.

Investigate

In Claude Code, type:

Why is TagManager._tag_counts prefixed with an underscore?
What does Python's underscore convention mean, and how does
it differ from truly private attributes in other languages?

Compare the AI's explanation to what you saw: the underscore is a convention, not an enforcement mechanism. Python trusts developers to respect the signal.

Modify

Add a most_popular() method to TagManager that returns the tag with the highest count:

def most_popular(self) -> str | None:
if not self._tag_counts:
return None
return max(self._tag_counts, key=self._tag_counts.get)

Test it with three tags of different counts. Verify it returns the correct tag.

Make [Mastery Gate]

Write a SearchIndex class that CONTAINS a dict[str, list[Note]] and exposes two methods:

  • index(note: Note) -> None: for each word in note.body.lower().split(), adds the note to that word's list in the dict
  • search(keyword: str) -> list[Note]: returns all notes indexed under that keyword, or an empty list if the keyword is not found

Do not inherit from dict. Use composition. The internal dict should be self._index.

Write 4 tests using the TDG cycle:

  1. Indexing a note makes it findable by a word in its body
  2. Searching a keyword that does not exist returns an empty list
  3. Indexing two notes with the same word returns both from search
  4. SearchIndex does not have a popitem 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: Show Me the Unwanted Methods

If NoteCollection inherited from list, how many methods would
it inherit? List all of them and mark which ones are dangerous
for a collection that should only hold Note objects.

What you're learning: Seeing the full list of inherited methods makes the problem concrete. When you count 30+ methods and realize most of them bypass your domain rules, the case for composition becomes obvious. You are building judgment about interface control.

Prompt 2: Refactor to Composition

Here is a class that uses inheritance:

class TaskList(list):
def add_task(self, task):
self.append(task)
def pending(self):
return [t for t in self if not t.done]

Refactor it to use composition instead. Show me the before
and after, and explain what methods are no longer accessible.

What you're learning: Refactoring from inheritance to composition is a skill you will use throughout your career. The AI shows the mechanical transformation, and you learn to recognize which methods disappear and why that is a feature, not a loss.

Prompt 3: When IS Inheritance OK?

Give me 3 examples where inheritance is genuinely correct
and 3 examples where people use inheritance but should use
composition. For each one, apply the is-a vs has-a test and
explain the decision.

What you're learning: The is-a vs has-a test is a decision tool, not a rule to memorize. Seeing both correct and incorrect uses builds pattern recognition. After this, you will be able to evaluate your own class designs without asking.


James looks at the SmartNotesApp class: NoteCollection, TagManager, each doing its own job. "A warehouse has shelves, forklifts, and an inventory system," he says. "The shelves do not inherit from the warehouse. The forklifts do not inherit from the shelves. Each piece exists on its own. The warehouse coordinates them. If I replace the inventory system, the shelves keep working."

Emma smiles. "Your warehouse-has-shelves framing is the best version of this I have heard. I usually say 'car has engine,' and someone always asks if a car IS a vehicle. You skip that trap entirely."

"So now I know inheritance works when something IS a specialized version. And composition works when something HAS parts. But when I face a real design decision next week, how do I choose?"

"You know inheritance," Emma says. "You know its dangers. You know composition. But when you face a new design, how do you decide? The design decision framework gives you a concrete question to ask every time. Next lesson."