Composition
Build a feature by holding a collaborator instead of extending one, and judge the trade: fewer classes and swappable parts against more wiring and indirection.
Composition is one field and one forwarding call: the class holds a collaborator and passes work to it instead of becoming it. That change removes both failure modes of the previous two lessons — no promise to callers to break, no base class calling back into you — and introduces costs of its own worth naming before adopting it everywhere.
The same feature, twice
A report renderer that should cache its output. By inheritance:
class CachedReport(Report):
def __init__(self):
super().__init__()
self._cache = {}
def render(self, query):
key = query.key()
if key not in self._cache:
self._cache[key] = super().render(query)
return self._cache[key]
class CachedReport extends Report {
private final Map<String, String> cache = new HashMap<>();
@Override String render(Query q) {
return cache.computeIfAbsent(q.key(), k -> super.render(q));
}
}
By composition, the cache becomes a thing the renderer has:
class CachingReports:
def __init__(self, inner, cache):
self._inner, self._cache = inner, cache
def render(self, query):
key = query.key()
hit = self._cache.get(key)
if hit is None:
hit = self._inner.render(query)
self._cache.put(key, hit)
return hit
record CachingReports(Reports inner, Cache cache) implements Reports {
@Override public String render(Query q) {
String hit = cache.get(q.key());
if (hit == null) {
hit = inner.render(q);
cache.put(q.key(), hit);
}
return hit;
}
}
Roughly the same number of lines. The difference only shows when a requirement moves.
What changes when the requirement moves
"Turn caching off in tests." Inheritance: construct a Report instead of a
CachedReport, so every test knows both class names and picks between them.
Composition: pass a no-op cache to the same constructor — one argument, one
type, no branching in the setup.
"Cache PDF output but not CSV." Inheritance: a flag inside CachedReport —
the format axis leaking into the caching class — or a PdfCachedReport.
Composition: wrap only the PDF renderer.
"The cache is shared with the search feature now." Inheritance cannot share:
the cache is a field of one object, born and dying with it. Composition passes
the same Cache instance to both.
The arithmetic makes the choice, not taste. Three axes of variation — 4 output
formats, 3 caching modes, 2 data sources — cost 4 × 3 × 2 = 24 leaf classes
under inheritance, because every combination needs a name. Under composition
they cost 4 + 3 + 2 = 9 parts and one constructor call that picks three. Add a
fourth axis with 2 options and the first doubles to 48 while the second grows
to 11.
The rule of thumb, and when to break it
Prefer composition. Use inheritance when substitutability genuinely holds — the test from inheritance and substitutability — and the hierarchy is stable. Three cases pass both:
- Inheriting a contract, not code. A Java interface, a Python ABC with no concrete methods, a C++ pure virtual class — see designing to an interface. There is no implementation to be coupled to, so none of the fragile base class applies. This is the cheap kind, and not what the rule of thumb warns about.
- A closed set of variants.
sealed interface Shape permits Circle, Rectin Java: the set will not grow, the base is yours, and exhaustive matching over it is the point. - A framework extension point documented as one, where the self-calls are specified and treated as API.
Implementation inheritance outside those three is where the cost lives.
Strategy: composing behaviour
When what varies is an algorithm rather than a collaborator, make the algorithm
an object. Instead of PriorityCheckout extends Checkout:
class Checkout:
def __init__(self, pricing):
self._pricing = pricing
def total(self, cart):
return self._pricing.price(cart)
record Checkout(PricingRule pricing) {
Money total(Cart cart) { return pricing.price(cart); }
}
PricingRule is one method, so in Java it is a lambda and in Python a plain
function; a strategy does not have to be a class. The difference from
inheritance is binding time: a subclass fixes the algorithm when the object is
created and for its whole life, while a strategy field can be swapped between
calls — a pricing rule that changes at midnight, a retry policy that hardens
after the third failure.
Injection: composing collaborators
Same move, aimed at the things a class talks to. A class that builds its own collaborators has hard-coded them:
class OrderService:
def __init__(self):
self._db = Postgres("prod-primary") # nobody can change this
Take them as parameters instead and the caller decides. The payoff is loudest in tests. A test that talks to a real database pays a datacenter round trip of 0.5 ms per query; at 200 queries a test that is 100 ms of pure network before any work happens. An in-memory double is a main memory reference at 100 ns, about 5,000× cheaper per call — 0.5 ms ÷ 100 ns — and needs no mocking library, because a second implementation of the same interface is the double.
This is dependency inversion under another name: OrderService depends on a
Store interface it declares, and both implementations depend on that
interface.
What it costs
Composition is not free, and pretending otherwise is how codebases end up with nine layers of one-method wrappers.
- Wiring. Somebody has to construct the graph. Keep it in one composition
root —
main, a factory, a container — because if construction is scattered, every call site now knows the full assembly. - Indirection. Reading
self._pricing.price(cart)tells you nothing about what runs; you have to find the construction site. Inheritance at least prints the parent's name at the top of the file. This is the cost that makes composition feel worse in small programs. - Forwarding code. A wrapper over a 15-method interface is 15 one-line
methods. Kotlin has delegation by keyword, Python has
__getattr__, Java and C++ have neither and you type all fifteen. - A pointer chase. A collaborator you just touched is in L1 at 1 ns; a cold one is a main memory reference at 100 ns. Irrelevant everywhere except an inner loop, and worth measuring rather than assuming.
In an interview
"Favour composition over inheritance" on its own is a slogan, and interviewers hear it several times a day. Convert it into the arithmetic: "these are two independent axes, so subclassing costs me the product and delegation costs me the sum — 12 classes against 7 objects."
Then say what you would still use inheritance for, unprompted. Naming the exception — interface implementation, a sealed set of variants, a documented extension point — is what shows you have a rule rather than a reflex.
The mistake that loses points is composing everything, including what does not vary. A wrapper that exists so a single implementation can be "swapped later" is indirection bought against no requirement. One implementation and no test seam needed means the direct call is the better design.
Check yourself
A logging wrapper and a caching wrapper both implement Reports. What can you
do with them that two subclasses of Report could not?
Stack them in either order, and choose the order at runtime: caching outside logging logs only misses, logging outside caching logs every call. Subclasses would need a class per combination, and the order would be fixed at compile time.
Two axes: 4 export formats and 3 destinations. Give the class count both ways, and say what the composed version needs that the subclassed one does not.
Inheritance:
4 × 3 = 12leaf classes. Composition:4 + 3 = 7objects and one constructor call. The composed version needs a place that decides which three parts to assemble — a composition root — which the subclassed version gets for free from thenewexpression.
When would you still write extends?
When the parent is a contract with no implementation to depend on, when the set of subclasses is closed and yours, or when the base is a documented extension point. In all three the substitution check passes and the base's self-calls are either absent or specified.