A bundle can contain products or other bundles, and the pricing code should not care which it is holding. How would you model that?
A composite gives leaves and containers one interface so callers recurse without branching. The two decisions that matter are whether child management appears on that shared interface, and which operations genuinely aggregate - because price, availability and delivery date do not combine the same way.
What the interviewer is scoring
- Whether the candidate decides deliberately if child management belongs on the shared interface
- Does the design prevent a cycle at the moment the link is created
- That operations which do not aggregate by summing are identified rather than assumed uniform
- Whether repeated traversal cost and lazily loaded children are considered
- Does the candidate say when two fixed levels make this pattern over-engineering
Answer
One interface so the caller stops branching
The requirement is that pricing code holds something and asks it for a price without knowing whether it is one product or a tree of them. That is the whole value of a composite: recursion replaces the caller's type check, and the code that walks the catalogue contains no instanceof and no special case for depth.
public sealed interface CatalogueItem permits Product, Bundle {
Money price();
boolean available();
}
public record Product(Sku sku, Money price, int stock) implements CatalogueItem {
@Override public boolean available() { return stock > 0; }
}
A sealed hierarchy is worth reaching for where the language offers it, because the set of node kinds in a composite is genuinely closed — leaf and container — and closing it lets exhaustiveness be checked rather than assumed.
Where add and remove live is the real decision
The classic formulation puts add and remove on the shared interface so that clients treat every node identically, and then a leaf has to do something when asked to accept a child. Throwing is the usual choice, and it means every implementation of the type advertises a capability a third of them do not have, which is exactly the contract-with-holes problem that makes callers start checking types again.
The alternative keeps the shared interface to what the reading clients need and puts child management on the container class only.
public final class Bundle implements CatalogueItem {
private final List<CatalogueItem> children = new ArrayList<>();
private final DiscountPolicy discount;
// add lives here rather than on CatalogueItem, so no leaf has to reject it.
public void add(CatalogueItem child) {
// Guard before linking. Once the edge exists, price() never returns.
if (createsCycle(child)) throw new IllegalArgumentException("bundle would contain itself");
children.add(child);
}
@Override public Money price() {
Money sum = children.stream()
.map(CatalogueItem::price)
.reduce(Money.zero(), Money::plus);
return discount.applyTo(sum); // a bundle's price is not merely the sum
}
@Override public boolean available() {
return children.stream().allMatch(CatalogueItem::available);
}
}
That asymmetry is a deliberate trade and you should name it as one. Uniform child management buys code that can restructure a tree without knowing what it holds, which matters for a generic tree editor and matters very little for a catalogue. Narrow interfaces buy the guarantee that a leaf can never be handed a child, checked at compile time. The tell is who the callers are: pricing, rendering and availability all read, and only the small amount of code that builds catalogues writes — and that code always knows it is holding a bundle.
The invariant is acyclicity
A composite is only a tree if you make it one, and nothing in the code above prevents a bundle being added to itself, directly or through three intermediaries. The consequence is not a wrong answer, it is unbounded recursion the first time anybody asks for a price, so the failure arrives as a stack overflow in a request handler far from the code that created the edge.
Check at insertion, not at traversal. Walking the prospective child's descendants looking for this costs the size of that subtree once per structural change, which is cheap because catalogues are edited far less often than they are priced. Doing it the other way round — tolerating cycles and defending during traversal with a visited set — means every price calculation pays for a structural mistake that should have been rejected.
Two extra guards are worth the lines. Bound the depth, because a legitimately deep tree still recurses once per level, and an accidental thousand-level nesting is indistinguishable from a cycle to the caller. And decide whether the same item may appear under two parents: sharing a subtree is often fine and turns your tree into a directed acyclic graph, which changes the cycle check, breaks any assumption of a single parent, and makes double-counting possible in any aggregate that sums.
Not every operation aggregates the same way
This is where composites are usually got wrong, and it is the most interesting part of the design. The pattern makes it effortless to add an operation that recurses, which quietly invites the assumption that every property of a container is a fold over its children with the same combining rule. It is not.
Price sums, but only until a bundle carries its own discount, at which point the container has state of its own and the aggregate is a sum followed by a policy. Availability is a conjunction, not a sum — a bundle is orderable only if every part is. Weight sums. Delivery date does not sum at all: it is the maximum of the children's dates, because the parcel leaves when the slowest item is ready. Restricted delivery flags propagate upwards as a disjunction — one hazardous item makes the whole bundle hazardous.
So the design point is that each operation carries its own combining rule, and the interface should force you to state it rather than letting a default sum be inherited by accident. Rounding is the trap inside the trap: rounding each leaf's price to the penny and then summing gives a different total from summing exactly and rounding once, and if the customer-facing line items and the order total are computed by different routes, they will disagree by pennies. Pick one, apply it in one place, and make the other view derive from it.
Traversal cost and when not to use this at all
Recursion over an in-memory tree is free enough to ignore. Recursion over a tree whose children are loaded lazily from a database is one query per node, and a composite is a very effective way to write an N+1 problem that no single line of code reveals. If nodes are persisted, load the subtree in one query — a recursive query, a materialised path, or a closure table — and hand the assembled tree to the pattern. Keep the loading strategy outside the nodes, because a node that can fetch its own children is a node you cannot test or reason about.
Caching needs the same care. Structure changes rarely and prices change often, so the safe thing to memoise is the shape of the tree rather than the money, and any cached aggregate needs an invalidation path that reaches every ancestor of a changed leaf. Getting that wrong produces a stale total on a page that shows the correct line items, which is the kind of bug customers report and nobody can reproduce.
Finally, know when not to. If the nesting is two fixed levels — categories that contain products, and never categories that contain categories — a list is the correct model and a composite adds recursion, a cycle check and an abstract type in exchange for nothing. The pattern earns its cost when the depth is genuinely unbounded and callers genuinely should not care.
The uniform interface is the easy half. The design work is deciding that child management does not belong on it, rejecting cycles where the edge is created, and stating a combining rule per operation instead of assuming everything sums.
Likely follow-ups
- A bundle ends up inside itself through three intermediate bundles. Where does your check catch it, and what does that check cost?
- Prices must round to the penny. Do you round per leaf or once at the top, and what does each choice break?
- How would you persist this tree, and which query does your choice make expensive?
- Most of the tree is unchanged between two requests. What is safe to cache and what is not?
Related questions
- Given a binary tree, decide whether it is a valid binary search tree.mediumAlso on recursion and invariants5 min
- An order moves through a set of states with rules about which transitions are allowed. How would you design that so it does not become a nest of if statements?mediumAlso on invariants5 min
- Numbers arrive one at a time and after each one I want the running median. How would you keep it?mediumAlso on invariants4 min
- Build a shared-expense tracker. Three people split a bill of ten pounds evenly - write the classes, and tell me where the missing penny goes.mediumAlso on invariants6 min
- Count the ways to place N queens on an N by N board so that no two attack each other.hardAlso on recursion5 min
- You are given a sorted array that has been rotated by an unknown amount. Find a target value in logarithmic time.mediumAlso on invariants4 min
- Serialise a binary tree to a string and reconstruct exactly the same tree from it. What has to be in the string?mediumAlso on recursion4 min
- Given the heights of a row of bars, work out how much rainwater is trapped between them. Do it in constant extra space.mediumAlso on invariants4 min