TRENDING
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
October 28, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Two earthworms lying together on wet brown leaf litter, photographed close up
October 28, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
Two opened leaves of the Verona palimpsest, with an older, darker layer of writing still showing through the newer script written over it
October 28, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
A bronze-framed hourglass with white sand beside a small Ferris wheel ornament on a wooden table against a teal wall
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Six weathered stone columns of the Temple of Apollo at Delphi, Greece, standing on a stone platform above a valley of olive groves
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 28, 2026
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 269 Posts
News 272 Posts
Learning Hub 241 Posts
Home/Learning Hub/How to Build the Composite Design Pattern in Python to Stop Type-Checking Every Object in a Tree
Learning Hub

How to Build the Composite Design Pattern in Python to Stop Type-Checking Every Object in a Tree

Build the Composite design pattern from scratch in Python with a file storage tree and an org chart, so a single object and an entire group can be treated through one identical interface.

September 10, 2026 19 Min Read
45

Picture a small backend codebase that has to answer a simple question: how big is this thing? Sometimes “this thing” is a single file. Sometimes it is an entire directory tree, or a whole team of employees, or a nested menu of UI widgets. The natural first instinct is to write a function that checks what type of object it is looking at, then branches: one path for a single item, another path for a group. That works, right up until you need a second operation, and a third, and someone adds a new kind of item six months later. Now every one of those branching functions needs an update, and it is easy to miss one.

Table Of Content

  • What Problem Does the Composite Pattern Solve?
  • Prerequisites
  • The Three Layers of a Composite Structure
  • The Component
  • The Leaf
  • The Composite
  • Step 1: Define the Component Interface
  • Step 2: Build the Leaf
  • Step 3: Build the Composite
  • Step 4: Treat a Single File and an Entire Tree the Same Way
  • Step 5: Gotcha One, a Broken Subclass Fails Loudly, Not Silently
  • Step 6: Gotcha Two, Sharing One Object Between Two Parents
  • Step 7: Gotcha Three, When a Directory Contains Itself
  • Step 8: Gotcha Four, Deep Trees and Python’s Recursion Limit
  • Step 9: Proving the Pattern Generalizes With an Org Chart
  • Step 10: The Trade-Off Nobody Mentions, Adding a New Operation Later
  • Step 11: Verify Everything With an Automated Test Suite
  • When to Use the Composite Pattern, and When Not To
  • Common Mistakes to Watch For
  • How to Confirm Your Own Composite Is Working
  • Next Steps

The Composite design pattern eliminates that branching entirely. It lets you define one common interface that both a single object and a group of objects implement, so calling code never has to ask “am I looking at one thing or many things?” It just calls the method. In this tutorial you will build the pattern from scratch in Python, twice, in two different domains, and along the way you will personally reproduce four real bugs that the pattern makes easy to fall into if you are not careful: a naive type-check that breaks the moment a new type shows up, a subclass that silently accepts an interface it does not fully implement, a shared object that mutates in two places at once, and a directory that ends up containing itself. Every code sample and every line of output in this post comes from code that was actually run.

What Problem Does the Composite Pattern Solve?

Say you are building a small tool that reports how much disk space a project folder is using, similar in spirit to the Unix du command. You start with two kinds of things: a file, which has a size, and a folder, which contains files and other folders. Without any shared structure, a natural first attempt looks like this:

class FileNaive:
    def __init__(self, name, size_bytes):
        self.name = name
        self.size_bytes = size_bytes


class FolderNaive:
    def __init__(self, name):
        self.name = name
        self.contents = []


def naive_total_size(item):
    if isinstance(item, FileNaive):
        return item.size_bytes
    elif isinstance(item, FolderNaive):
        total = 0
        for child in item.contents:
            total += naive_total_size(child)
        return total
    else:
        raise TypeError(f"Unknown item type: {type(item)}")


def naive_count_files(item):
    if isinstance(item, FileNaive):
        return 1
    elif isinstance(item, FolderNaive):
        total = 0
        for child in item.contents:
            total += naive_count_files(child)
        return total
    else:
        raise TypeError(f"Unknown item type: {type(item)}")

Counting files needed the exact same isinstance branching, written all over again in a second function. Now suppose a real file-walking tool eventually needs to represent a symbolic link too, a third kind of entry that is neither a plain file nor a folder. Someone adds a SymlinkNaive class and appends one to the tree without touching either function, because from the caller’s point of view, nothing looks obviously wrong:

class SymlinkNaive:
    def __init__(self, name, target):
        self.name = name
        self.target = target

Run the complete script, which builds the tree, prints the correct totals, then appends the symlink entry and tries to size the tree again:

$ python step1_naive.py
Total size: 6400 bytes
File count: 3
Total size after adding a symlink entry:
Traceback (most recent call last):
  File "step1_naive.py", line 58, in <module>
    print(naive_total_size(project))
  File "step1_naive.py", line 19, in naive_total_size
    total += naive_total_size(child)
  File "step1_naive.py", line 22, in naive_total_size
    raise TypeError(f"Unknown item type: {type(item)}")
TypeError: Unknown item type: <class '__main__.SymlinkNaive'>

That is the exact pain the Composite pattern removes. The logic diverges every time a new type shows up, and every existing operation has to be revisited to account for it. The fix is to stop asking “what type is this?” at all, and instead give every type in the hierarchy the same interface.

Prerequisites

  • Python 3.10 or newer (this tutorial was built and tested on Python 3.13.14, using only the standard library for the core pattern)
  • Comfortable with basic Python classes, inheritance, and what an abstract method is at a conceptual level; you do not need prior experience with structural design patterns specifically
  • pytest for the final verification step (pip install pytest; tested against pytest 9.1.1)
  • A terminal and a working directory you can throw away afterward

The Three Layers of a Composite Structure

Every Composite implementation, regardless of domain, is built from the same three pieces working together.

The Component

The Component is an abstract interface (in Python, typically an abstract base class) that declares every operation both individual objects and groups must support. Nothing in the Component knows or cares whether a particular instance is a single item or a container of other items.

The Leaf

A Leaf is a concrete implementation of the Component with no children. It represents one individual object: a single file, a single employee, a single menu item. Leaves do the actual work, since they have nobody to delegate to.

The Composite

A Composite is also a concrete implementation of the Component, but it holds a collection of children, and each child is itself a Component, meaning it can be either a Leaf or another Composite nested inside. A Composite implements each operation by asking its children for their answers and combining the results. This is what makes arbitrarily deep nesting fall out of the design for free, rather than requiring special-case code for “two levels deep” versus “five levels deep.”

Refactoring.Guru’s reference on the Composite pattern uses boxes and products as its running example: a box can hold products directly, or it can hold smaller boxes, which can hold even smaller boxes, and so on, with every level answering to the same “what is your total price” question. This tutorial builds the equivalent structure for two different domains: a file storage tree, and an organizational chart.

Step 1: Define the Component Interface

Start by declaring the shared contract as an abstract base class using Python’s built-in abc module. Three operations will be needed for the file storage tool: getting total size, counting files, and rendering a readable tree view.

from abc import ABC, abstractmethod


class FileSystemItem(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def get_size(self) -> int:
        """Return this item's size in bytes."""

    @abstractmethod
    def count_files(self) -> int:
        """Return how many files this item contains (a File counts as 1)."""

    @abstractmethod
    def render(self, indent: int = 0) -> str:
        """Return an indented, human-readable tree representation."""

Save this as component.py. Nothing here is instantiable on its own. Python’s own documentation is explicit about this constraint: “A class that has a metaclass derived from ABCMeta cannot be instantiated unless all of its abstract methods and properties are overridden,” per the abc module documentation. That single sentence is doing a lot of quiet enforcement work, as Step 5 demonstrates directly.

Step 2: Build the Leaf

A File is the simplest possible thing in this hierarchy: it has a name and a size, and no children. Save this as leaf.py:

from component import FileSystemItem


class File(FileSystemItem):
    def __init__(self, name: str, size_bytes: int):
        super().__init__(name)
        self.size_bytes = size_bytes

    def get_size(self) -> int:
        return self.size_bytes

    def count_files(self) -> int:
        return 1

    def render(self, indent: int = 0) -> str:
        return f"{'  ' * indent}{self.name} ({self.size_bytes} bytes)"

Every method here does the obvious, direct thing. There is no delegation, because a Leaf has nothing to delegate to.

Step 3: Build the Composite

A Directory is where the interesting behavior lives. It holds a list of children, and each of the three interface methods works the same way: ask every child for its own answer, then combine those answers. Save this as directory.py:

from component import FileSystemItem


class Directory(FileSystemItem):
    def __init__(self, name: str):
        super().__init__(name)
        self._children: list[FileSystemItem] = []

    def add(self, item: FileSystemItem) -> None:
        self._children.append(item)

    def remove(self, item: FileSystemItem) -> None:
        self._children.remove(item)

    def get_size(self) -> int:
        return sum(child.get_size() for child in self._children)

    def count_files(self) -> int:
        return sum(child.count_files() for child in self._children)

    def render(self, indent: int = 0) -> str:
        lines = [f"{'  ' * indent}{self.name}/"]
        for child in self._children:
            lines.append(child.render(indent + 1))
        return "\n".join(lines)

Notice that get_size() never checks whether a given child is a File or another Directory. It just calls child.get_size() and trusts that whatever it gets back is correct, because the Component interface guarantees that method exists on anything that could possibly be in that list. This version is intentionally missing one safety check; Step 7 comes back and adds it once you have seen why it is needed.

Step 4: Treat a Single File and an Entire Tree the Same Way

Build a small, realistically nested project tree, three levels deep in one branch, and call the exact same methods on the whole tree and on a single standalone file:

from leaf import File
from directory import Directory


def build_project_tree() -> Directory:
    project = Directory("project")
    project.add(File("README.md", 1200))

    src = Directory("src")
    src.add(File("main.py", 3400))
    src.add(File("utils.py", 1800))

    lib = Directory("lib")
    lib.add(File("parser.py", 2600))
    lib.add(File("__init__.py", 0))
    src.add(lib)

    docs = Directory("docs")
    docs.add(File("guide.md", 5000))

    project.add(src)
    project.add(docs)
    return project


if __name__ == "__main__":
    project = build_project_tree()
    print(project.render())
    print()
    print("Whole-tree size:", project.get_size(), "bytes")
    print("Whole-tree file count:", project.count_files())
    print()

    single_file = File("standalone.txt", 42)
    print("Single leaf size:", single_file.get_size(), "bytes")
    print("Single leaf file count:", single_file.count_files())
$ python step4_demo.py
project/
  README.md (1200 bytes)
  src/
    main.py (3400 bytes)
    utils.py (1800 bytes)
    lib/
      parser.py (2600 bytes)
      __init__.py (0 bytes)
  docs/
    guide.md (5000 bytes)

Whole-tree size: 14000 bytes
Whole-tree file count: 6

Single leaf size: 42 bytes
Single leaf file count: 1

That 14000-byte total is 1200 plus 3400 plus 1800 plus 2600 plus 0 plus 5000, correctly aggregated across three levels of nesting (project contains src, which contains lib). The calling code never once asked whether it was talking to a File or a Directory. project.get_size() and single_file.get_size() are the exact same method call, and both return a correct answer for what they represent. That uniform treatment, not the tree structure itself, is the actual point of the pattern.

Step 5: Gotcha One, a Broken Subclass Fails Loudly, Not Silently

What happens if someone writing a new leaf type forgets to implement one of the three required methods? Try building a symlink-like class that only implements two of the three:

from component import FileSystemItem


class BrokenSymlink(FileSystemItem):
    def __init__(self, name: str, target: str):
        super().__init__(name)
        self.target = target

    def get_size(self) -> int:
        return 0

    def render(self, indent: int = 0) -> str:
        return f"{'  ' * indent}{self.name} -> {self.target}"

    # count_files() was never implemented.
$ python step5_broken_leaf.py
Traceback (most recent call last):
  File "step5_broken_leaf.py", line 19, in <module>
    link = BrokenSymlink("latest.py", "src/main.py")
TypeError: Can't instantiate abstract class BrokenSymlink without an implementation for abstract method 'count_files'

This is exactly the ABCMeta enforcement mentioned back in Step 1, and it is worth appreciating what it does for you. In a plain-interface language with duck typing, a class missing one method would work fine right up until the moment something actually calls the missing method, possibly weeks later and three layers deep in a call stack. Here, Python refuses to even create an instance of the broken class. The bug surfaces at construction time, with a message that names the exact missing method, not somewhere downstream when count_files() finally gets called on a tree that happens to contain one.

Step 6: Gotcha Two, Sharing One Object Between Two Parents

A Composite tree in Python is built out of ordinary object references, and Python does not copy objects when you pass them around. That means the exact same File instance can end up living inside two different Directory objects at once, which is sometimes intentional (a shared configuration file, a shared license) and sometimes a genuine bug (someone meant to add a copy).

from leaf import File
from directory import Directory

shared_license = File("LICENSE", 1071)

backend = Directory("backend")
backend.add(File("server.py", 4200))
backend.add(shared_license)

frontend = Directory("frontend")
frontend.add(File("app.js", 3100))
frontend.add(shared_license)

print("backend size:", backend.get_size())
print("frontend size:", frontend.get_size())

print()
print("Someone edits the license text, so its size changes...")
shared_license.size_bytes = 1200

print("backend size:", backend.get_size())
print("frontend size:", frontend.get_size())
$ python step6_aliasing.py
backend size: 5271
frontend size: 4171

Someone edits the license text, so its size changes...
backend size: 5400
frontend size: 4300

Both totals moved after a single mutation, because backend and frontend hold references to the exact same File object, not independent copies of it. If your mental model of the tree is “each directory owns its own private files,” this will surprise you the first time it happens. If you actually want shared, deduplicated files across a tree (a real pattern called Flyweight, closely related to Composite), this behavior is exactly what you want and you should document it as intentional. Either way, the fix or the design decision has to be deliberate, and this is the kind of bug that only shows up once two parts of a tree happen to reference the same leaf, which is easy to miss in a code review that only looks at one file at a time.

Step 7: Gotcha Three, When a Directory Contains Itself

Here is a subtler mistake. Imagine code that is supposed to link a backup directory to an older archive, but a copy-paste error re-adds an ancestor instead of a sibling:

from leaf import File
from directory import Directory

backups = Directory("backups")
archive = Directory("archive")

backups.add(File("2026-01.tar", 900))
backups.add(archive)

archive.add(File("2025-12.tar", 850))
archive.add(backups)  # bug: re-adds an ancestor, creating a cycle

print("Calling backups.get_size() ...")
print(backups.get_size())
$ python step7_cycle.py
Calling backups.get_size() ...
Traceback (most recent call last):
  ...
  File "directory.py", line 16, in get_size
    return sum(child.get_size() for child in self._children)
  File "directory.py", line 16, in <genexpr>
    return sum(child.get_size() for child in self._children)
  File "directory.py", line 16, in get_size
    return sum(child.get_size() for child in self._children)
RecursionError: maximum recursion depth exceeded

backups contains archive, and now archive contains backups, so get_size() bounces back and forth between the two forever, consuming one stack frame per bounce until Python’s interpreter stack limit stops it. Nothing about the Component interface prevents this on its own; a tree is really just a directed graph that a well-behaved caller promises not to make cyclic, and nothing enforces that promise unless you build it in.

The fix belongs in add() itself: before accepting a new child, check whether adding it would make the current directory reachable from that child, which is exactly what a cycle is. This is the version of directory.py the rest of this tutorial uses:

from component import FileSystemItem


class CycleError(ValueError):
    """Raised when adding an item would make a directory contain itself."""


class Directory(FileSystemItem):
    def __init__(self, name: str):
        super().__init__(name)
        self._children: list[FileSystemItem] = []

    def add(self, item: FileSystemItem) -> None:
        if self._would_create_cycle(item):
            raise CycleError(
                f"cannot add {item.name!r} to {self.name!r}: "
                f"{self.name!r} already lives inside {item.name!r}"
            )
        self._children.append(item)

    def remove(self, item: FileSystemItem) -> None:
        self._children.remove(item)

    @property
    def children(self) -> tuple[FileSystemItem, ...]:
        return tuple(self._children)

    def _would_create_cycle(self, item: FileSystemItem) -> bool:
        if item is self:
            return True
        if not isinstance(item, Directory):
            return False
        # Walk item's subtree iteratively (no recursion) looking for self.
        stack = list(item._children)
        while stack:
            node = stack.pop()
            if node is self:
                return True
            if isinstance(node, Directory):
                stack.extend(node._children)
        return False

    def get_size(self) -> int:
        return sum(child.get_size() for child in self._children)

    def count_files(self) -> int:
        return sum(child.count_files() for child in self._children)

    def render(self, indent: int = 0) -> str:
        lines = [f"{'  ' * indent}{self.name}/"]
        for child in self._children:
            lines.append(child.render(indent + 1))
        return "\n".join(lines)

_would_create_cycle() deliberately walks the candidate item’s subtree with an explicit list used as a stack, instead of recursing, so that checking for a cycle never itself becomes vulnerable to the same class of problem it is guarding against. A public read-only children property is added alongside it, returning a tuple copy rather than the live internal list, so nothing outside the class can mutate _children directly; Step 8 relies on that property from outside the class.

Re-run the same buggy scenario against the guarded version:

from leaf import File
from directory import Directory, CycleError

backups = Directory("backups")
archive = Directory("archive")

backups.add(File("2026-01.tar", 900))
backups.add(archive)
archive.add(File("2025-12.tar", 850))

try:
    archive.add(backups)
except CycleError as exc:
    print(f"Blocked: {exc}")

print("backups.get_size() =", backups.get_size())
$ python step7_cycle_fixed.py
Blocked: cannot add 'backups' to 'archive': 'archive' already lives inside 'backups'
backups.get_size() = 1750

The cycle never gets created, and the tree still works correctly for its real, non-cyclic contents: 900 plus 850 is 1750.

Step 8: Gotcha Four, Deep Trees and Python’s Recursion Limit

Even a perfectly acyclic tree can break the same way if it gets deep enough, because every method in this hierarchy calls itself indirectly through its children. Build a chain of two thousand nested directories, one inside the next, and try to size it:

import sys
from leaf import File
from directory import Directory

print("sys.getrecursionlimit() =", sys.getrecursionlimit())

DEPTH = 2000
root = Directory("root")
current = root
for i in range(DEPTH):
    child = Directory(f"level-{i}")
    current.add(child)
    current = child
current.add(File("payload.bin", 999))

print(f"Built a chain of {DEPTH} nested directories.")
print("Calling root.get_size() ...")
try:
    print(root.get_size())
except RecursionError as exc:
    print(f"RecursionError: {exc}")
$ python step8_deep_chain.py
sys.getrecursionlimit() = 1000
Built a chain of 2000 nested directories.
Calling root.get_size() ...
RecursionError: maximum recursion depth exceeded

Python’s default recursion limit is 1000 call frames on this installation, confirmed directly by sys.getrecursionlimit(), and a 2000-level chain blows through it well before reaching the file at the bottom. The Python documentation is specific about why that limit exists at all: it “prevents infinite recursion from causing an overflow of the C stack and crashing Python,” according to the sys module documentation. Calling sys.setrecursionlimit() with a bigger number does not add more real stack space; it just moves the wall further away, and pushing it too high risks a hard interpreter crash instead of a clean, catchable exception. Deeply nested trees are rare in a literal file system, but they are common in domains like deeply nested UI component trees, recursive configuration includes, or generated data, so the risk is worth designing around rather than assuming away.

The more robust fix is to stop relying on Python’s call stack at all for this specific operation, and walk the tree iteratively with an explicit list acting as a work queue instead:

from component import FileSystemItem
from leaf import File
from directory import Directory


def total_size_iterative(root: FileSystemItem) -> int:
    total = 0
    stack = [root]
    while stack:
        node = stack.pop()
        if isinstance(node, Directory):
            stack.extend(node.children)
        else:
            total += node.get_size()
    return total
$ python step8_iterative_fix.py
Chain depth: 2000
total_size_iterative(root) = 999

Same 2000-level chain, same correct answer (the single 999-byte payload file at the bottom), and no recursion error, because the “recursion” now lives in a Python list on the heap instead of in C stack frames. This function reads node.children, the public property added in Step 7, rather than reaching into the private _children list directly, which is exactly the kind of external, cross-class traversal that property exists to support cleanly.

Step 9: Proving the Pattern Generalizes With an Org Chart

Everything so far has been about files and folders. To confirm the pattern is not secretly specific to file systems, build a second, unrelated hierarchy: an engineering org chart that needs to answer two questions uniformly, for a single employee or for an entire department: how many people, and how much combined salary.

from abc import ABC, abstractmethod


class OrgUnit(ABC):
    def __init__(self, name: str):
        self.name = name

    @abstractmethod
    def headcount(self) -> int: ...

    @abstractmethod
    def total_salary(self) -> int: ...


class Employee(OrgUnit):
    def __init__(self, name: str, salary: int):
        super().__init__(name)
        self.salary = salary

    def headcount(self) -> int:
        return 1

    def total_salary(self) -> int:
        return self.salary


class Team(OrgUnit):
    def __init__(self, name: str):
        super().__init__(name)
        self._members: list[OrgUnit] = []

    def add(self, unit: OrgUnit) -> None:
        self._members.append(unit)

    def headcount(self) -> int:
        return sum(member.headcount() for member in self._members)

    def total_salary(self) -> int:
        return sum(member.total_salary() for member in self._members)

Build a small three-level org (an engineering manager, plus a backend team and a frontend team reporting to them) and call the same two methods on individual employees, sub-teams, and the whole department:

backend = Team("Backend")
backend.add(Employee("Priya", 145_000))
backend.add(Employee("Marcus", 138_000))

frontend = Team("Frontend")
frontend.add(Employee("Ines", 132_000))
frontend.add(Employee("Tomas", 129_000))

engineering = Team("Engineering")
engineering.add(Employee("Dana (Eng Manager)", 168_000))
engineering.add(backend)
engineering.add(frontend)

print("Backend headcount:", backend.headcount(), "salary:", backend.total_salary())
print("Frontend headcount:", frontend.headcount(), "salary:", frontend.total_salary())
print("Engineering headcount:", engineering.headcount(), "salary:", engineering.total_salary())

solo = Employee("Contractor Alex", 90_000)
print("Solo contractor headcount:", solo.headcount(), "salary:", solo.total_salary())
$ python step9_org_chart.py
Backend headcount: 2 salary: 283000
Frontend headcount: 2 salary: 261000
Engineering headcount: 5 salary: 712000
Solo contractor headcount: 1 salary: 90000

283000 is 145000 plus 138000, 261000 is 132000 plus 129000, and Engineering’s total of 712000 correctly rolls up the manager’s own salary plus both sub-teams: 168000 plus 283000 plus 261000. Nothing about Employee or Team shares a single line of code with File or Directory, yet the shape of the solution, one interface, one Leaf, one Composite that delegates and aggregates, is identical. That repeatability across unrelated domains is what makes this a named, reusable pattern rather than a one-off trick.

Step 10: The Trade-Off Nobody Mentions, Adding a New Operation Later

Composite makes it easy to add new kinds of Leaf or Composite without touching existing code, which is a real strength; Refactoring.Guru frames this directly as an application of the Open/Closed Principle, since you can introduce new element types “without breaking the existing code, which now works with the object tree.” But the mirror image of that strength is a real cost: adding a brand-new operation to the interface means touching every single class in the hierarchy, not just adding one new class.

Prove it by adding one new operation, finding the highest-paid person in a (sub)tree, to the org chart hierarchy already built:

class OrgUnit(ABC):
    ...
    @abstractmethod
    def highest_paid(self) -> "Employee":
        """Return the single highest-paid Employee in this unit's subtree."""


class Employee(OrgUnit):
    ...
    def highest_paid(self) -> "Employee":
        return self


class Team(OrgUnit):
    ...
    def highest_paid(self) -> "Employee":
        candidates = [m.highest_paid() for m in self._members]
        return max(candidates, key=lambda emp: emp.salary)
$ python step10_new_operation.py
Highest paid in Engineering: Dana (Eng Manager) ($168,000)

That correctly finds Dana at $168,000 over Priya’s $145,000, the next highest. But look at how many places changed for one new operation: the abstract method on OrgUnit, the trivial implementation on Employee, and the recursive implementation on Team. With two concrete classes, that is a minor chore. Refactoring.Guru’s own pros-and-cons list names this exact limitation: “it might be difficult to provide a common interface for classes whose functionality differs too much,” and with ten or twenty different Leaf and Composite subtypes in a large real system (contractors, interns, managers with different bonus structures, and so on), adding one new operation means editing all of them. That is precisely the situation where many engineers reach for a different, related pattern called Visitor instead, which moves each operation into its own class rather than spreading it across the object hierarchy; Refactoring.Guru notes this relationship directly, observing “you can use Visitor to execute an operation over an entire Composite tree.” Composite and Visitor are not competitors so much as tools for opposite kinds of change: Composite is cheap when you add new object types and expensive when you add new operations, and Visitor is the other way around.

Step 11: Verify Everything With an Automated Test Suite

Manually running scripts and reading output is how this tutorial was built, but a real codebase needs an automated safety net. This suite exercises the file storage hierarchy, including both fixed bugs from Steps 7 and 8:

import pytest
from leaf import File
from directory import Directory, CycleError


def build_tree() -> Directory:
    project = Directory("project")
    project.add(File("README.md", 1200))
    src = Directory("src")
    src.add(File("main.py", 3400))
    src.add(File("utils.py", 1800))
    project.add(src)
    return project


def test_leaf_size_and_count():
    f = File("standalone.txt", 42)
    assert f.get_size() == 42
    assert f.count_files() == 1


def test_composite_aggregates_children():
    tree = build_tree()
    assert tree.get_size() == 1200 + 3400 + 1800
    assert tree.count_files() == 3


def test_empty_directory_has_zero_size():
    empty = Directory("empty")
    assert empty.get_size() == 0
    assert empty.count_files() == 0


def test_nested_composite_aggregates_recursively():
    root = Directory("root")
    level1 = Directory("level1")
    level2 = Directory("level2")
    level2.add(File("deep.txt", 10))
    level1.add(level2)
    root.add(level1)
    assert root.get_size() == 10
    assert root.count_files() == 1


def test_direct_self_reference_is_blocked():
    a = Directory("a")
    with pytest.raises(CycleError):
        a.add(a)


def test_indirect_cycle_is_blocked():
    a = Directory("a")
    b = Directory("b")
    a.add(b)
    with pytest.raises(CycleError):
        b.add(a)


def test_children_property_returns_a_copy():
    d = Directory("d")
    d.add(File("x.txt", 1))
    snapshot = d.children
    d.add(File("y.txt", 2))
    assert len(snapshot) == 1
    assert len(d.children) == 2


def test_shared_leaf_reflects_mutation_in_both_parents():
    shared = File("LICENSE", 100)
    a = Directory("a")
    b = Directory("b")
    a.add(shared)
    b.add(shared)
    shared.size_bytes = 500
    assert a.get_size() == 500
    assert b.get_size() == 500
$ python -m pytest test_composite.py -v
collecting ... collected 8 items

test_composite.py::test_leaf_size_and_count PASSED
test_composite.py::test_composite_aggregates_children PASSED
test_composite.py::test_empty_directory_has_zero_size PASSED
test_composite.py::test_nested_composite_aggregates_recursively PASSED
test_composite.py::test_direct_self_reference_is_blocked PASSED
test_composite.py::test_indirect_cycle_is_blocked PASSED
test_composite.py::test_children_property_returns_a_copy PASSED
test_composite.py::test_shared_leaf_reflects_mutation_in_both_parents PASSED

8 passed in 0.04s

All eight pass, including the two tests that specifically encode Step 7’s cycle protection (test_direct_self_reference_is_blocked and test_indirect_cycle_is_blocked) and the one that pins down Step 6’s aliasing behavior as an intentional, tested fact rather than an accident someone might “fix” later without realizing it was deliberate.

When to Use the Composite Pattern, and When Not To

  • Use it when your data is genuinely tree-shaped and callers need to treat single items and groups through one interface: file systems, UI component trees, organizational structures, nested menus, arithmetic expression trees.
  • Use it when you expect to add new leaf or composite types over time more often than you expect to add brand-new operations. That is the direction this pattern optimizes for.
  • Skip it if your objects do not actually share meaningful behavior. Forcing a common interface onto classes whose functionality differs too much, per Refactoring.Guru’s own caution, means overgeneralizing the Component interface until it is harder to understand than the branching code it replaced.
  • Skip it, or pair it with Visitor, if operations change far more often than the set of types does. Step 10 showed exactly what that cost looks like in miniature.
  • Always decide the cycle-prevention and aliasing questions on purpose. Steps 6 and 7 are not edge cases you can safely ignore; they are two of the first things that go wrong the first time a Composite tree is built and modified by more than one piece of code.

Common Mistakes to Watch For

  • Forgetting one abstract method on a new subclass. Python catches this immediately at instantiation time with a clear TypeError, as Step 5 showed; do not suppress or work around that error, fix the missing method.
  • Assuming child objects are always independent copies. They are references. If two parents hold the same Leaf, mutating it affects both, which is sometimes exactly what you want and sometimes a bug; Step 6 covers both readings.
  • Never validating against cycles. A tree is only a tree because callers cooperate to keep it acyclic. If more than one part of a codebase can call add(), validate it, the way Step 7’s _would_create_cycle() does.
  • Writing every traversal recursively without a depth plan. Recursive methods are the natural, readable way to write Composite operations, and they are fine for realistic file systems and org charts. For domains where depth is attacker-controlled or otherwise unbounded (parsing untrusted nested data, for example), have an iterative fallback ready, as in Step 8.

How to Confirm Your Own Composite Is Working

Before trusting a Composite hierarchy in real code, check all of the following, each one demonstrated concretely in this tutorial:

  • Calling the same method on a lone Leaf and on the root of a deeply nested Composite returns correct, independently verifiable results (Step 4).
  • A subclass that skips a required method fails at construction time with a specific error naming the missing method, not silently at call time later (Step 5).
  • You know, explicitly, whether your tree allows the same child object to appear in more than one parent, and you have decided whether that is intended (Step 6).
  • add() rejects anything that would create a cycle, with a test proving both the direct case (a directory adding itself) and the indirect case (Step 7).
  • You have measured, or at least estimated, how deep your real trees can get, and you have an iterative fallback ready if recursion depth is a realistic risk (Step 8).
  • An automated test suite pins down the aggregation math, the cycle protection, and the aliasing behavior, so a future refactor cannot silently break any of them (Step 11).

Next Steps

From here, a natural extension is adding a find(predicate) method that searches an entire Composite tree and returns every matching Leaf, which forces you to decide how a Composite reports “not found” versus “found nothing here, keep looking,” a small design question with more nuance than it first appears. If you want to see the trade-off from Step 10 solved properly rather than worked around, look into the Visitor pattern next, which separates each new operation into its own class instead of adding a method to every existing one. If your Composite trees might come from untrusted input rather than code you wrote yourself, the cycle-detection technique from Step 7 and the iterative traversal from Step 8 both apply directly and are worth combining. For a different, complementary way of thinking about restructuring existing object hierarchies safely, see this site’s tutorial on preparing a legacy application for migration using seams and adapters, and for another classic pattern built from scratch with the same emphasis on catching real failure modes by actually running the code, see how to build a bulkhead in Python to stop a slow dependency from starving the rest.

Tags:

Design PatternsObject-Oriented ProgrammingPythonSoftware Architecture

Share

A mine rescue worker in breathing apparatus examines a small canary cage used to detect toxic gas, 1928 U.S. Bureau of Mines photo
Previous Post

Anthropic’s Own Alignment Lead Puts Odds on AI Killing Everyone After a Researcher Quits

A small wicket gate built into a much larger railway level-crossing gate, providing a pedestrian path beside the main barrier
Next Post

CISA Orders Federal Agencies to Patch a Critical NetScaler Flaw by September 12

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
28 Oct
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
28 Oct
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
Trending
October 28, 2026
NIST’s Final Post-Quantum Transition Report Puts Every Quantum-Vulnerable Public-Key Scheme on One 2030 and 2035 Clock, and Three Federal Documents Word the Deadline Three Ways
October 28, 2026
Honeypots Recorded Probes Against Oracle E-Business Suite CVE-2026-101767 Seven Days After Its Patch, and the Finder’s Write-Up Is Dated Patch Day
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026