Module 5: Patterns for Structuring and Adapting
6. Composite: treating the whole and the part alike
Description
By the end of this lesson you'll be able to recognize a tree-shaped structure when you have one in front of you, and to write the pattern that handles it with elegance: Composite, where a group of things gets used exactly like a single thing. You'll see Boletia's legitimate case — discounts, which can be a single one or a nested combination — and you'll walk away with the property that defines the pattern: the composite IS a component, so whoever uses it never asks what they got.
And you'll walk away, above all, with the other half: when not to use it. Composite is the most elegant pattern in this module and the one most often forced where it doesn't belong, because once you learn it you start seeing trees in every list. Half the lesson is going to be dedicated to the three honest problems it brings — the order of application, the asymmetry between leaf and branch, and depth — and to the sign that you're putting it where there's no tree.
This matters because it's the pattern where the difference between knowing the shape and having judgment shows the most. The module's other three families solve a problem you recognize by the pain: the leaking SDK, the ten copied steps, the retry you have to repeat four times. Composite doesn't hurt until you actually need it, and by then you've already applied it three times where it wasn't needed.
Connection to the module: the three previous patterns solved contact with what you don't control — Adapter translates, Facade simplifies, Decorator adds. This is the odd one out in the family: it doesn't solve contact with anything external, but a data-shape problem. It's here because it shares the mechanics — an object containing others — and because, honestly, it's where the brake is needed the most. Lesson 7 is that brake applied to the whole module: the three questions that decide whether a class was needed or a function would have done. And lesson 8 — the project — asks you to isolate the integration with the payment provider by choosing the minimal pattern that does the job, which is exactly the judgment this lesson starts to install.
Folders and files
Open your computer's file explorer and look at what you can do with a file: drag it, copy it, delete it, rename it, ask how big it is, put it inside a folder.
Now look at what you can do with a folder: exactly the same. You drag it, copy it, delete it, rename it, ask how big it is, put it inside another folder.
That's the whole pattern, and it's worth pausing on how strange it is. A folder and a file are deeply different things: one has content, the other has children. And yet the operating system let you use them the same way, and that's why you've never once in your life had to ask yourself "is this thing I'm about to drag a file or a folder?" You just drag it.
Notice three consequences.
The first: "how big is it" has the same answer in both cases, but it's calculated differently. A file knows its size directly. A folder asks each child and adds them up. And if a child is another folder, that one does the same thing. The recursion is on the inside and whoever's asking never sees it.
The second: nesting has no artificial limit. A folder inside a folder inside another one works the same way. Nobody had to code the three-level case: it comes free from a folder being able to contain folders.
And the third, which is the pattern's warning: not everything is the same between the two. You can add children to a folder; not to a file. That's where the symmetry breaks, and that crack is this pattern's real problem — the one we're about to discuss seriously.
One last detail from the analogy, to calibrate when it's worth it. The file system is a real tree: folders inside folders, no fixed depth, thousands of nodes. If your computer could only have one folder with files inside and nothing else, all of this would be an unnecessary luxury: a list would do. Composite pays for itself with your tree's real depth.
What a Composite is, in one sentence
A Composite is an object that contains several objects of the same type as itself and behaves like a single one.
The property that defines it, stated precisely: the composite implements the same interface as its children. A group of discounts is a discount. A folder is a file system element. That's why whoever uses it never asks what they got.
It sounds like Decorator, and it's worth separating them now, because in the diagram they look almost the same:
| Decorator | Composite | |
|---|---|---|
| How many it contains | One | Several |
| What for | Adding behavior around it | Treating a group as a unit |
| What happens if you remove it | The business result doesn't change | You lose children: everything changes |
| How it's used | Stacked at the construction point | Built as a tree, often from data |
The mechanical rule: if it contains one, it's a Decorator; if it contains several, it's a Composite. And there's a case where they genuinely touch — a Composite with a single child behaves like a Decorator that adds nothing — which is a curiosity, not a problem.
The anatomy has three pieces, and for the first time in the module there are three, not four:
1. The component. The shared contract, fulfilled by both leaves and branches. At Boletia it's Discount, with a method that says how much it discounts. This contract has to be small: the more methods it has, the harder it'll be for a branch to fulfill all of them sensibly.
2. The leaf. The simple case, the one with no children: PercentageOff, FixedAmountOff. It does the real work.
3. The composite. The one with children, which fulfills the same contract by asking them: AllOf, BestOf. Its implementation is almost always a traversal of the children and a way to combine their answers.
Worked example: Boletia's discounts
Here's the case, and it comes from a real business need.
Boletia started with simple discounts: a percentage off for a promo code. Then the sales team started asking for things:
- "Ten percent off for students." Easy.
- "Two hundred pesos off if you buy four tickets or more." Easy.
- "Both at once, if the customer qualifies for both." That's no longer a rule, it's a combination.
- "The press code gives twenty-five percent, but it doesn't stack with anything: the customer gets the best of both worlds, not both." Now there's a combination of a different kind.
- "For the Cumbre Festival presale: the best between (student + bulk purchase, stackable) and (press code)." And now there's a combination inside another one.
That last request is the tell. The moment a combination can contain another combination, you have a tree, and trying to solve it with a flat list produces code that grows with every new campaign.
What the attempt without the pattern looks like. This is the code that used to be in pricing/discounts.py:
# File: pricing/discounts.py — BEFORE
def apply(order, subtotal):
"""Applies whichever discounts apply. Grows with every campaign."""
campaign = campaigns.for_event(order.event_id)
if campaign.kind == "student_percentage":
return subtotal * Decimal("0.10")
if campaign.kind == "bulk_fixed":
return Decimal("200") if len(order.ticket_ids) >= 4 else Decimal("0")
if campaign.kind == "student_and_bulk":
total = subtotal * Decimal("0.10")
if len(order.ticket_ids) >= 4:
total += Decimal("200")
return total
if campaign.kind == "best_of_student_bulk_or_press":
stacked = subtotal * Decimal("0.10")
if len(order.ticket_ids) >= 4:
stacked += Decimal("200")
press = subtotal * Decimal("0.25") if order.has_press_code else Decimal("0")
return max(stacked, press)
return Decimal("0")
Look at the fourth branch. The "student" logic is written three times, the "bulk purchase" logic three times, and every new combination the sales team asks for is going to copy them again. On the fifth campaign, someone is going to change the student percentage in one branch and not the other two, and for a month the same discount is going to be worth a different amount depending on the campaign. This is the same mistake you've already seen three times in this module: the same decision written multiple times.
And there's a worse problem, the one that makes this case specifically call for a Composite and not a Strategy: the sales team is going to keep inventing combinations, and each one is a new way of assembling the same pieces. What's needed isn't new behaviors; it's new combinations. That's a tree.
Step 1 — Define the contract, and keep it small.
# File: pricing/discount.py
from decimal import Decimal
from typing import Protocol
class Discount(Protocol):
"""A discount knows how much it discounts and knows how to explain itself.
Two methods, no more. A small contract is what lets a GROUP of discounts
fulfill it the same way a single one does: every method you add is a
method the branches will have to invent a way to fulfill.
"""
def amount_for(self, order, subtotal: Decimal) -> Decimal:
"""How much gets discounted. Always positive or zero, never negative."""
...
def describe(self) -> str:
"""Text to show the customer why we're giving them a discount."""
...
Notice the design decision: amount_for returns how much gets discounted, not the final price. If it returned the final price, composing would be a mess — does the group apply the second discount on top of the already-reduced price? — and the rules would end up coupled to each other. By returning an amount, each discount is independent and the group decides how to combine. The shape of the contract decides whether the Composite is easy or impossible.
Step 2 — The leaves.
# File: pricing/rules.py — the leaves
@dataclass(frozen=True)
class PercentageOff:
"""A percentage of the subtotal, if the customer meets the condition."""
percent: Decimal # 0.10 = 10%
label: str
applies_to: Callable[[object], bool] = lambda order: True
def amount_for(self, order, subtotal: Decimal) -> Decimal:
if not self.applies_to(order):
return Decimal("0")
# Rounded to two decimals here, not at the end: the customer sees
# this amount in the breakdown, and it has to match what they're charged.
return (subtotal * self.percent).quantize(Decimal("0.01"), ROUND_HALF_UP)
def describe(self) -> str:
return f"{self.label} ({self.percent:.0%})"
@dataclass(frozen=True)
class FixedAmountOff:
"""A fixed amount, if the customer meets the condition."""
amount: Decimal
label: str
applies_to: Callable[[object], bool] = lambda order: True
def amount_for(self, order, subtotal: Decimal) -> Decimal:
if not self.applies_to(order):
return Decimal("0")
# We never discount more than the subtotal: a negative total would
# mean paying someone money for buying.
return min(self.amount, subtotal)
def describe(self) -> str:
return f"{self.label} (${self.amount})"
Step 3 — The composites. Here's the pattern:
# File: pricing/combinations.py — the composites
@dataclass(frozen=True)
class AllOf:
"""Every discount that applies, summed up. It's one more Discount.
All of them are calculated on the SAME subtotal, not chained. See the
note about order below: it's the most important decision in this file.
"""
children: tuple[Discount, ...]
label: str = "Combined discounts"
def amount_for(self, order, subtotal: Decimal) -> Decimal:
total = sum((child.amount_for(order, subtotal) for child in self.children),
Decimal("0"))
return min(total, subtotal) # the cap also applies to the group
def describe(self) -> str:
applied = [c.describe() for c in self.children]
return f"{self.label}: " + " + ".join(applied)
@dataclass(frozen=True)
class BestOf:
"""The best discount for the customer. It's also a Discount.
Used when the promotions do NOT stack and the business decided the
customer should get the best one.
"""
children: tuple[Discount, ...]
label: str = "Best available promotion"
def amount_for(self, order, subtotal: Decimal) -> Decimal:
if not self.children:
return Decimal("0")
return max(child.amount_for(order, subtotal) for child in self.children)
def describe(self) -> str:
return f"{self.label} among: " + " | ".join(c.describe() for c in self.children)
Stop on one line from each, because that's where the pattern's essence lies. AllOf.amount_for doesn't know what its children are. They can be two percentages, or a percentage and another BestOf with four children inside. It asks each one and adds them up. That ignorance is exactly what makes the nesting come free.
Step 4 — Build the campaign's tree.
# The Cumbre Festival campaign, exactly as the sales team asked for it.
is_student = lambda order: order.customer.is_student
is_bulk = lambda order: len(order.ticket_ids) >= 4
has_press = lambda order: order.has_press_code
cumbre_presale = BestOf((
AllOf((
PercentageOff(Decimal("0.10"), "Student", applies_to=is_student),
FixedAmountOff(Decimal("200"), "Purchase of 4 or more", applies_to=is_bulk),
)),
PercentageOff(Decimal("0.25"), "Press code", applies_to=has_press),
))
And the pricing calculator, which used to have the if cascade, now has this:
# File: pricing/calculator.py — AFTER
def total_for(order) -> Decimal:
subtotal = sum(price_of(t) for t in order.tickets)
discount = campaigns.discount_for(order.event_id) # a Discount, whichever
return subtotal - discount.amount_for(order, subtotal)
Two lines, and they don't ask whether the discount is one or five nested ones. That's the pattern's payoff, stated precisely.
What to expect from this refactor. Three things, and the third is the one almost never mentioned.
The first: adding a campaign is no longer touching discount code. The Cumbre Festival campaign is a data expression built with pieces that already exist. The next one — "the best between the press code and (student + presale + bulk purchase)" — doesn't need new classes either: it's another tree with the same pieces. The classes stopped growing with the campaigns.
The second: the duplication disappeared. The ten-percent student discount exists in one place. If it changes to twelve tomorrow, it changes once.
And the third, which is this pattern's hidden benefit: the tree can be traversed for things besides calculating. Look at describe(): it composes the same way amount_for does, so a readable breakdown for the customer comes for free. With the if cascade that was impossible without writing a second, parallel if — and those two ifs would drift out of sync. When you have a tree of objects, you can traverse it to calculate, to explain, to validate a campaign before publishing it, or to show the sales team a summary of which promotions are active. The structure serves more than one question:
# A breakdown for the customer, free, from the same tree:
def breakdown(discount, order, subtotal) -> list[tuple[str, Decimal]]:
"""Which discounts applied and for how much. Only the ones that gave something."""
if isinstance(discount, (AllOf, BestOf)):
return [line for child in discount.children
for line in breakdown(child, order, subtotal)]
amount = discount.amount_for(order, subtotal)
return [(discount.describe(), amount)] if amount > 0 else []
That isinstance deserves a note, because it contradicts what we've been saying. It's acceptable here for a concrete reason: this function lives outside the tree, and its job is precisely to tell leaves apart from branches. If the isinstance showed up inside amount_for, it would be a serious mistake — it would mean the contract isn't enough. The rule: isinstance on a Composite smells bad in the code that uses it and is normal in the code that traverses it.
The pattern's three honest problems
Now the part materials about Composite usually skip.
Problem 1: order and non-commutativity
AllOf sums the discounts calculated all on the same subtotal. That was a decision, and the opposite decision — applying them in a chain, each one on the previous one's result — gives a different number.
With a subtotal of 1000 pesos, a 10%, and a fixed 200:
- On the same subtotal: 100 + 200 = 300 discount. Total: 700.
- Chained, percentage first: 1000 − 100 = 900; 900 − 200 = 700. Total: 700. Same.
- Chained, fixed amount first: 1000 − 200 = 800; 800 − 10% = 720. Total: 720. Different.
Twenty pesos of difference just from the order. With a hundred thousand transactions a year, that's real money, and it's the kind of thing finance discovers six months later.
The lesson isn't "pick option A": it's that that decision belongs to the business, not the programmer, and it has to be written somewhere visible. In the code above it's resolved — all on the same subtotal — and commented in AllOf's docstring. What isn't acceptable is for it to be an accident of how someone happened to write the loop.
And there's a corollary for design: if your combination depends on order, the Composite becomes fragile, because the tree structure doesn't communicate order in an obvious way. If the business genuinely needs ordered chains, the class should be called InSequence, not AllOf, so the name shouts what it does.
Problem 2: the asymmetry between leaf and branch
You add children to a folder; not to a file. In software, that crack forces a decision the original catalog discusses and almost nobody mentions.
Option A — transparency: you put add(child) and remove(child) in the shared contract, so leaves and branches are truly identical. The cost: leaves have to implement methods that make no sense, usually by raising an error.
# ⚠️ Transparency: the leaf has methods it can't fulfill.
class PercentageOff:
def add(self, child):
raise TypeError("A simple discount has no children")
That raise is a real problem: it means the contract promises something not every implementation fulfills, and whoever uses it has to know which ones do. It's a violation of the substitution principle.
Option B — safety: the shared contract only has what both can fulfill — amount_for and describe — and only the composites have a way of receiving children, in their constructor. The cost: whoever wants to manipulate the tree has to know what type they're looking at.
In Python, option B is clearly better, and it's the one we use: the composites receive their children in the constructor and are immutable (frozen=True, tuple instead of list). A tree of discounts gets assembled whole and doesn't get modified afterward, which also eliminates an entire category of bugs: nobody is going to add a child to a campaign in production halfway through a calculation.
The general rule: only put in the shared contract what leaf and branch can fulfill sensibly. If you find yourself putting in methods one of the two has to reject, the contract is too big.
Problem 3: depth
A two-level tree is readable. A five-level one isn't.
# ⚠️ This is syntactically valid and humanly unreadable.
BestOf((
AllOf((
BestOf((PercentageOff(...), AllOf((FixedAmountOff(...), PercentageOff(...))))),
FixedAmountOff(...),
)),
BestOf((AllOf((PercentageOff(...), PercentageOff(...))), FixedAmountOff(...))),
))
Nobody can say how much that discounts without running it, and if the number comes out wrong, nobody can say why. The pattern places no limit — that's its appeal and its danger — so the limit has to be put in by hand.
Two practical approaches. The first: a validation when assembling the campaign, that rejects trees deeper than the business needs.
def depth_of(discount) -> int:
if not isinstance(discount, (AllOf, BestOf)):
return 1
return 1 + max(depth_of(c) for c in discount.children)
def validate_campaign(discount) -> None:
"""Boletia's campaigns don't need more than three levels. A deeper tree is
almost always an assembly mistake, not a real campaign."""
if depth_of(discount) > 3:
raise ValueError("The campaign is too complex to explain to a customer")
The second, more fundamental: if campaigns get built from the admin panel, whoever builds them isn't writing code but filling out a form, and that form is the limit. When a Composite gets fed from data — a configuration JSON, a table — depth gets controlled in the interface that produces it, and that's usually better than validating it afterward.
There's a rule that sums up the problem and is worth keeping handy: if you can't explain the tree to a customer in one sentence, the customer isn't going to understand their invoice either. A discount nobody can explain is a business problem before it's a code problem.
When NOT to use Composite
And now the part that's needed more than everything above it.
When there's no nesting. This is by far the most common case. If your discounts are always a flat list — "apply all that apply" — you don't have a tree: you have a list. And a list gets solved with a list:
# No pattern. And that's fine.
def total_discount(discounts, order, subtotal):
return min(sum(d.amount_for(order, subtotal) for d in discounts), subtotal)
Three lines, zero new classes, and anyone understands it. The question that decides is literal: can a group contain another group? If the answer is no — and today, not in two years — there's no tree, and Composite is dead weight.
When the group behaves differently and whoever uses it needs to know. The pattern pays for itself in that whoever uses it never asks what they got. If in your case you have to ask — because a group needs extra parameters, or because its result means something else — the abstraction is lying, and you're going to end up with isinstance in the code that uses it. That's worse than never having added it.
When the tree has exactly two levels and always the same ones. An event has tickets, and tickets don't have tickets. That's not a tree, it's a one-to-many relationship, and forcing a Composite there produces classes where a list would have done. The sign: if your "composite" never contains another composite, it isn't a composite.
When what varies is a value, not a structure. If all your "combinations" are actually the same operation with a different number, what you need is a configuration table, not a hierarchy of objects. It's the same mistake you saw in module 4 with taxes: turning what was data into classes.
An honest closing note on Boletia's case. Discounts do earn the pattern, and for a concrete reason worth being able to say out loud: the sales team asks for new combinations every quarter, those combinations genuinely nest, and the alternative — the if cascade — was already duplicating each rule's logic in every campaign. If Boletia had a single discount per event and no combinations, this entire file would be a mistake. Elegance doesn't justify the pattern; the tree does.
Common mistakes
Seeing trees where there are lists (judgment). What happens: someone learns the pattern and applies it to the first collection they find. An order's tickets become a TicketComposite; the notification channels, a ChannelGroup; a form's steps, a tree. None of those nest: an order has tickets and tickets don't have tickets. The result is a class hierarchy where a list would have done, with the usual cost — more files, more indirection, one more jump to understand what's happening — and no gain. Why it happens: the pattern's shape fits any collection, so the false signal is constant. And once you've learned the pattern, seeing it feels like judgment. How to spot it: the one-second question is "can a group contain another group?" If it never happens in your domain, there's no tree. A complementary check: look at your code and see whether any composite actually contains another composite in practice. If the maximum depth across the whole system is two, you have a list with classes. How to fix it: replace the composite with a list and a function that traverses it. You'll delete two classes and nobody will notice they're gone.
Putting methods the branch can't fulfill into the shared contract (implementation). What happens: the contract starts with amount_for and describe, which both fulfill. Then someone adds percent — because a screen wants to show the percentage — and now AllOf has to invent what to return: the sum of its children's percentages? The first one? None? Any answer is a lie. And from then on, whoever uses the discount has to know whether they got a leaf or a branch, which is exactly what the pattern was supposed to prevent. Why it happens: the contract grows from concrete UI requests, which almost always think in terms of the simple case. How to spot it: for every method in the contract, ask what a group of five nested discounts returns. If the answer is "it depends" or "it doesn't make sense," that method doesn't belong in the contract. How to fix it: take it out. If the screen needs the percentage, let it get it by traversing the tree with an external function — like breakdown — which can tell leaves from branches. The shared contract is the intersection of what leaf and branch can do, not the union.
Leaving the tree mutable (implementation). What happens: the composites get written with a list and an add method, because that's how they look in almost every catalog example. Months later, someone builds a campaign, stores it in an in-memory cache so it doesn't get rebuilt on every purchase, and somewhere else in the code another add adds a child to it. From that moment on, every purchase for that event gets the extra discount, and the bug only shows up after a restart, when it disappears. Why it happens: the pattern's classic examples come from graphical interfaces, where the tree does change — you add a button to a panel — and that mutability gets copied into domains where it isn't needed. How to spot it: if your composites use list and have add/remove, ask whether the tree really changes after it's built. In business rules, almost never. How to fix it: frozen=True and tuple. An immutable tree can be cached, compared, and shared across threads without a second thought, and it eliminates an entire category of hard-to-reproduce bugs.
Exercises
Exercise 1 — Is there a tree? For each case, say whether a Composite applies and justify it with the lesson's question: can a group contain another group?
(a) A Boletia event has several ticket types — general, VIP, complimentary — and each type has a base price and a quota. (b) The admin panel's permissions: a user has roles, a role can include other roles — "manager" includes "box office operator" and "report viewer" — and each role grants individual permissions. (c) A sales report shows the event total, broken down by day, and each day broken down by ticket type. (d) Validating an order: you have to check that it has tickets, that the event hasn't started, that the customer doesn't exceed the per-person limit, and that the tickets are still available.
See solution
(a) No. A ticket type doesn't contain ticket types. It's a one-to-many relationship: an event has a list of types. It's the literal case of the first common mistake.
(b) Yes, and it's the textbook example after folders. A role can contain roles, those roles can contain others, and "can this user do X?" gets answered the same way whether you ask a single permission or a role with five levels inside. The nesting is real and comes from the domain, not the code. Watch what comes free and has to be handled: cycles — if "manager" includes "supervisor" and someone makes "supervisor" include "manager," the traversal never ends. A Composite over data a human edits needs cycle validation.
(c) No, even though it looks like it. There is a hierarchical structure in the presentation — total, days, types — but it's a hierarchy of fixed depth, always the same: a day never contains another day. That gets solved with groupings and sums, not a tree of objects. The sign is the fixed depth: a Composite pays for itself when the depth is variable.
(d) No: that's a list of validators. Each check is independent and none contains another. A list and a loop are enough. That said, there's a version of this case that would call for the pattern: if the business needed rules like "(A and B) or (C and D)" with free nesting — a rules engine — there'd be a real tree there. The difference between "a list of conditions" and "a tree of conditions" is exactly whether the business needs to combine with nested "or."
Why it works: three of the four look like candidates and aren't. The question "can a group contain another group?" rules them out in a second, and it's the only one you need. Notice also that in (c) and (d) there are nearby versions that would call for the pattern: the case isn't decided by the domain but by whether the nesting is real.
Exercise 2 — Add a new combination. The sales team asks for a campaign that can't be expressed today: "apply the first discount that matches and no others, in the order we define." It's what priority promotions do: if the customer has a press code, that one and nothing else; if not, check if they're a student; if not, check if they're buying in volume.
Write FirstMatch and then build the campaign with it. Pay attention to two things: how you know whether a child "matches," and what describe() should return.
See solution
# File: pricing/combinations.py
@dataclass(frozen=True)
class FirstMatch:
"""The first discount that gives something, in the given order. The rest get ignored.
Unlike AllOf and BestOf, here the ORDER of the children is the business
rule, so the name says it and the tuple preserves it.
"""
children: tuple[Discount, ...]
label: str = "Applied promotion"
def amount_for(self, order, subtotal: Decimal) -> Decimal:
for child in self.children:
amount = child.amount_for(order, subtotal)
if amount > 0:
return amount
return Decimal("0")
def describe(self) -> str:
return f"{self.label} (the first that applies from: " + \
", ".join(c.describe() for c in self.children) + ")"
# The priority campaign:
campaign = FirstMatch((
PercentageOff(Decimal("0.25"), "Press code", applies_to=has_press),
PercentageOff(Decimal("0.10"), "Student", applies_to=is_student),
FixedAmountOff(Decimal("200"), "Purchase of 4 or more", applies_to=is_bulk),
))
The two decisions:
How to know if a child "matches." The solution above uses amount > 0 as the criterion, and that's a decision with an odd case: a discount that applies but gives zero — a promotional 0%, or a fixed amount on a zero subtotal — gets treated as "doesn't apply" and moves on to the next one. In most businesses that's fine and is what a human would expect. If your business needs to distinguish "doesn't apply" from "applies and gives zero," the contract has to say so, and there the honest solution is for amount_for to return Decimal | None, or to add an applies_to(order) method to the shared contract. Both are defensible; what isn't is leaving the ambiguity undecided. This exercise exists so you trip over the fact that the component's contract decides which combinations are expressible.
What describe() returns. The solution above describes the structure — "the first that applies from: A, B, C" — which is correct for a method that doesn't receive the order. To show the customer which one actually applied you need the order, and that's breakdown's job, which does have it. The distinction is worth making: describe() explains the rule, breakdown() explains the result. Mixing the two up is what leads to describe starting to accept parameters and the contract growing.
If your solution added FirstMatch without touching any other class, that's the point of the exercise: a new combination is a new twenty-line class, and the leaves and the other combinations never find out. That's what the if cascade couldn't do.
Exercise 3 — Diagnose this Composite. This code showed up in a review. Find at least three problems.
class NotificationGroup:
"""Groups notification channels and treats them as one."""
def __init__(self):
self.channels = []
def add(self, channel):
self.channels.append(channel)
def send(self, customer, message):
for channel in self.channels:
if isinstance(channel, NotificationGroup):
channel.send(customer, message)
else:
if channel.is_available_for(customer):
channel.send(customer, message)
class EmailChannel:
def send(self, customer, message): ...
def is_available_for(self, customer): return customer.email is not None
def add(self, channel):
raise NotImplementedError("A channel has no children")
See solution
Problem 1: the isinstance is inside the traversal. The line if isinstance(channel, NotificationGroup) is the sign the pattern isn't working: if the group had to treat leaves and branches the same way, it wouldn't need to ask what each child is. The real cause is in the contract: is_available_for is a method the leaves have and the groups don't, so the group has to dodge around it. Consequence: the traversal knows the concrete types, and adding a third node type forces you to touch this if. Fix: either is_available_for enters the shared contract with a meaning a branch can fulfill — "at least one of my children is available" — or it leaves the contract and each leaf decides inside its own send whether it should act.
Problem 2: the leaf has an add that raises. It's the catalog's transparency option, with its full cost: EmailChannel promises a method it can't fulfill. Consequence: nobody can write generic code that adds children without wrapping it in a try, and a type checker doesn't help because the signature exists. Fix: take add out of the shared contract. Groups receive their children in the constructor and leaves don't have that method.
Problem 3: it's mutable and gets built empty. NotificationGroup() with no children and with add means a group can be left half-built and that someone can modify it later after storing it somewhere. Consequence: bugs like "this customer got two emails" or "the notices stopped arriving" become impossible to reproduce, because they depend on the order the code happened to run in. Fix: frozen=True, tuple, children in the constructor.
And a fourth, the most important one: there's probably no tree. Boletia's notification channels are email, SMS, and push. A channel doesn't contain channels, and in practice nobody has ever needed a group inside a group. This is the first common mistake: a list with classes. The most honest solution is for this file to disappear and leave a function:
def notify(channels, customer, message):
for channel in channels:
if channel.is_available_for(customer):
channel.send(customer, message)
Four lines, zero classes, and the three earlier problems stop existing because the structure that caused them disappears.
Why it works: the first three problems are real and can be fixed one by one, and that's the exercise's trap — it's entirely possible to spend an afternoon fixing a pattern that shouldn't have been there. Before improving an abstraction, it's worth asking whether it earns its place. It's module 2 showing up again, and it's the exact prelude to lesson 7.
Summary and next step
In this lesson you defined Composite: an object that contains several objects of the same type as itself and behaves like a single one. You saw its defining property — the composite IS a component, so whoever uses it never asks what they got — and the rule that separates it from Decorator: if it contains one, it's a Decorator; if it contains several, it's a Composite.
You worked through Boletia's legitimate case: discounts, which went from an if cascade with each rule's logic duplicated in every campaign to a tree of reusable pieces. And you saw that the main gain wasn't writing less, but that the classes stopped growing with the campaigns: a new campaign is a data expression, not new code. Plus the hidden benefit: a tree of objects can be traversed for more than one question — calculating, explaining, validating — and that self-composing describe() was impossible with the cascade.
You saw the three honest problems. Order: whether the discounts get applied on the same subtotal or in a chain changes the result, and that decision belongs to the business and has to be written down. Asymmetry: only put in the shared contract what leaf and branch can fulfill sensibly; the contract is the intersection, not the union. Depth: the pattern places no limit, and one has to be put in, because a tree nobody can explain produces invoices nobody can explain.
And you saw the four situations where it doesn't apply: when there's no real nesting, when the group behaves differently and whoever uses it needs to know, when the tree has a fixed depth, and when what varies is a value, not a structure. The one-second question that rules them all out: can a group contain another group?
Before moving on you should be able to: tell Composite apart from Decorator by the number of children; write a new combination without touching the leaves; decide what goes into the shared contract and what doesn't; and — above all — recognize a list disguised as a tree.
With this, the module's catalog closes: Adapter, Facade, Decorator, and Composite. Lesson 7 is the counterweight, and it arrives at exactly the right moment. After learning four ways to wrap things, the temptation to wrap everything is strong and feels like good judgment. We're going to install the brake with three questions you can answer in a minute — is there more than one implementation? do you need to swap it out in tests? is the external interface genuinely unstable? — and a rule that's going to decide half the cases you run into in your career: if all three are "no," wrap it simply and move on.
Resources
- Refactoring Guru — Composite — the pattern with its diagram and the classic boxes-within-boxes example. Read it noticing it presents the mutable variant as the normal one.
- Refactoring Guru — Composite vs Decorator — the comparison between the two, which look nearly identical in the diagram. Useful for fixing the one-versus-several rule.
- Python
dataclasses— frozen instances — how to make a tree of objects immutable in Python, which is the fix for the third common mistake. - Martin Fowler — Specification Pattern — a Composite applied to combinable business rules with "and," "or," and "not." It's the general version of exercise 1(d), and the best material for knowing when a rules engine earns its place.