Composition over inheritance
Delegation, the strategy you did not know you were writing, and how to refactor a class hierarchy into objects that collaborate.
"Favour composition over inheritance" is the most repeated design advice in Java and the least explained. The reason is mechanical, not aesthetic: inheritance couples a subclass to the implementation of its superclass, while composition couples an object only to the interface of the objects it holds. One of those couplings survives change; the other does not. This lesson is the mechanism, what forwarding costs (almost nothing, once the JIT has seen it), the two JDK classes that got it wrong permanently, and a hierarchy refactored step by step.
The same feature, two ways
We want a Set that counts how many elements were ever added.
By inheritance:
class CountingSet<E> extends HashSet<E> {
private int added;
@Override public boolean add(E e) { added++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
public int added() { return added; }
}This double-counts, because HashSet.addAll calls add internally. Fix it by not overriding addAll — and now your correctness depends on HashSet continuing to implement addAll that way, which is not documented and may change.
By composition:
class CountingSet<E> implements Set<E> {
private final Set<E> inner;
private int added;
CountingSet(Set<E> inner) { this.inner = inner; }
@Override public boolean add(E e) { added++; return inner.add(e); }
@Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return inner.addAll(c); }
public int added() { return added; }
// every other Set method forwards: size(), contains(), iterator(), remove(), ...
@Override public int size() { return inner.size(); }
@Override public boolean contains(Object o) { return inner.contains(o); }
// ...
}The wrapper holds a Set and forwards. It does not care whether inner.addAll calls inner.add, because those calls go to inner, not to the wrapper. It works with HashSet, TreeSet, LinkedHashSet, or another wrapper. The cost is the forwarding boilerplate — which is real, and which is why the standard library ships forwarding skeletons and why some codebases keep a ForwardingSet around.
These are the two relationships textbooks call IS-A and HAS-A. CountingSet extends HashSet declares that a counting set is a HashSet, and anywhere a HashSet is accepted, it must behave like one. The wrapper has a Set: it owns one as a field and decides which of its operations to expose. IS-A is inheritance and HAS-A is composition, and the question this lesson keeps asking is whether you need the first or only wanted the code that came with it.
Under the hood: why one coupling survives and the other does not
Look at the two versions through the dispatch table from the previous lesson. In the subclass, HashSet.addAll's internal add(e) is an invokevirtual on this, and this is a CountingSet, so the slot lookup lands on the override: the superclass's private call graph is wired into the subclass at every virtual call. Every internal call the superclass makes to one of its own overridable methods is an extension point whether the author meant it or not, and the author is free to add, remove or re-route those calls in any release.
In the wrapper, the only calls that reach CountingSet are the ones made by the caller, through the Set interface. inner's internal calls go to inner. The wrapper's surface is exactly the interface, no more, and its correctness depends on the interface's documented contract, which the library promised to keep. Inheritance inherits the superclass's self-use; composition inherits nothing.
The cost people fear, an extra hop per call, is what the JIT is best at removing. inner is a final field; at a call site that has only seen one wrapper class around one inner class, C2 inlines wrapper.add and then inner.add into the caller, and the hop is gone. Where it stays is a megamorphic site, five different Set implementations flowing through one call, and that site is slow with or without a wrapper.
Two JDK classes are the permanent exhibit. java.util.Stack extends Vector, so every Stack has insertElementAt and get(i), and a "stack" can be corrupted from the middle; the Javadoc itself now says to use Deque. java.util.Properties extends Hashtable<Object,Object>, so props.put(1, 2) compiles, breaks store(), and the class carries a getProperty that ignores non-string entries. Both are is-a claims that were really wants-the-implementation, frozen into the API since 1.0.
What you gain
- Independence from implementation details. Only the interface is a dependency.
- Choice at runtime. The wrapped object is a constructor argument; a test can pass a fake.
- Multiple behaviours. Wrap a wrapper:
new CountingSet<>(new SynchronizedSet<>(new HashSet<>())). This is the decorator pattern, and it does not exist with inheritance because a class has one superclass. - No exposure of the superclass API. A
CountingSet extends HashSetis aHashSet; callers can use anyHashSetmethod, including ones that bypass your counting. The wrapper exposes only what it chooses.
One thing to get right in a wrapper: identity. A Set wrapper that forwards equals to inner makes wrapper.equals(inner) true and inner.equals(wrapper) false, breaking symmetry; the usual choice is to leave equals and hashCode as identity on the wrapper, or to define value equality over the wrapper's own type only.
Strategy: the composition you already write
class Checkout {
private final PricingStrategy pricing; // interface
private final PaymentGateway payment; // interface
Checkout(PricingStrategy pricing, PaymentGateway payment) { ... }
Receipt complete(Cart cart) {
Money total = pricing.total(cart);
payment.charge(total, cart.card());
return new Receipt(cart, total);
}
}Checkout is composed of a pricing strategy and a gateway. Swap SeasonalPricing for StandardPricing by passing a different object; no subclass of Checkout needed. Every Spring service with constructor-injected collaborators is this pattern. You have been favouring composition all along; the rule is only about noticing when you reach for extends instead.
When inheritance is right
Two cases, and they are narrow:
- A genuine is-a with a designed-for-extension superclass.
HttpServlet,AbstractList, a framework base class that documents its hooks and never calls overridables from constructors. You are filling in a template the author intended you to fill in. - Sealed hierarchies of data.
sealed interface Shape permits Circle, Squarewith records — inheritance here declares a closed set of types for pattern matching, and there is no implementation to inherit, so the fragile-base problem cannot arise.
If a class is not abstract, was not documented for extension, and you want to subclass it to reuse a few methods — that is the case to convert to composition.
Walkthrough: unwinding a notification hierarchy
A codebase has NotificationSender → EmailSender → HtmlEmailSender → BrandedHtmlEmailSender, four levels, each overriding send and calling super.send somewhere in the middle. A new requirement: SMS notifications with the same branding. Nobody can say what super.send does at level three without reading all four classes.
Name what varies. Three things: the transport (email, SMS), the rendering (plain, HTML), the decoration (branding). Each level of the hierarchy was one of these, entangled with the others.
One interface per axis.
interface Transport { void deliver(Address to, String body); },interface Renderer { String render(Notification n); },interface Decorator { String decorate(String body); }.One class per variant.
EmailTransport,SmsTransport;PlainRenderer,HtmlRenderer;BrandingDecorator. Each is small, has no superclass, and is testable with a fake of its neighbours.One composer.
java class Sender { private final Transport transport; private final Renderer renderer; private final List<Decorator> decorators; Sender(Transport t, Renderer r, List<Decorator> ds) { transport = t; renderer = r; decorators = List.copyOf(ds); } void send(Notification n) { String body = renderer.render(n); for (var d : decorators) body = d.decorate(body); transport.deliver(n.to(), body); } }Delete the hierarchy. Branded HTML email is
new Sender(new EmailTransport(), new HtmlRenderer(), List.of(new BrandingDecorator())). Branded SMS, the new requirement, is one line: swap the transport. Under the hierarchy it would have been a fifth class that could not reuseHtmlEmailSender's branding because branding lived below email.
The four-class chain encoded three independent choices as one path through a tree. Composition makes them three constructor arguments, and the combinations that were impossible become free.
Refactoring a hierarchy
Given class PremiumCustomer extends Customer that overrides discount():
- Extract what varies into an interface:
interface DiscountPolicy { Money apply(Money m); }. - Implement it:
NoDiscount,PercentageDiscount,TieredDiscount. - Give
CustomeraDiscountPolicyfield and makeCustomerfinal. - Delete
PremiumCustomer. A premium customer is aCustomerwith aPercentageDiscount.
The result has no hierarchy, a customer whose tier can change without changing class, and a policy you can unit-test alone.
Try it yourself
Which will break?
Two ways to add logging to a Map: class LoggingMap<K,V> extends HashMap<K,V> overriding put and putAll, versus class LoggingMap<K,V> implements Map<K,V> wrapping a HashMap and forwarding. A JDK release changes HashMap.putAll from "calls put per entry" to "bulk copy". What happens to each?
Answer
The subclass changes behaviour silently: putAll no longer routes through its put, so entries added in bulk are no longer logged (or, if it overrode both and adjusted for double logging, now logs them twice as little). No compile error, no test failure unless a test asserted exact log counts. The wrapper is unaffected: its putAll logs and forwards to inner.putAll, and how inner implements that is inner's business.
Stack it
Using only wrappers, build a Set<String> that is (a) case-insensitive on insertion, (b) counts additions, and (c) rejects nulls with an IllegalArgumentException. In what order do you wrap, and does the order matter?
Answer
new NullRejectingSet<>(new CountingSet<>(new LowerCasingSet<>(new HashSet<>()))), outermost first. Order matters twice: the null check must be outermost so a null never reaches the counter or the lowercaser; and lowercasing must be inside the counter if "additions" should count the caller's calls, or outside it if it should count distinct lowercased values. With inheritance there is one order, the one the class hierarchy fixed, and stacking three behaviours needs a class per combination.
Is-a, or wants-the-code?
class Money extends BigDecimal, class UserId extends UUID, class AuditLog extends ArrayList<Entry>, class MyServlet extends HttpServlet. For each: inheritance or composition, and why?
Answer
Money: composition (and BigDecimal is not final only by historical accident); a Money must not expose multiply(BigDecimal) or lose its currency in arithmetic. UserId: UUID is final, so the question is moot; wrap it, or use a record with a UUID component. AuditLog: composition; an append-only log must not inherit remove and set. MyServlet: inheritance, the legitimate case; HttpServlet is abstract, documented for extension, and doGet is the hook its author intended.
Misconceptions
- "Composition means writing forwarding boilerplate forever." It means writing it once per interface you wrap; a
ForwardingSetbase or Lombok's@Delegateremoves the rest, and most wrappers implement a small domain interface, notSet. - "The extra call costs performance." A
finalfield forwarding through a monomorphic site is inlined by the JIT; the hop disappears in compiled code. - "Inheritance is the object-oriented way; composition is a workaround." The JDK's own mistakes (
Stack,Properties) are inheritance; its best designs (Collections.unmodifiableList,Readerwrappers, streams) are composition. - "If I own both classes, inheritance is safe." Until the day you refactor the superclass and forget the subclass's assumptions, which is the same day, one person later.
- "A wrapper must implement the whole interface." Only if callers need the whole interface. A wrapper that exposes three methods of a fifty-method type is hiding forty-seven ways to bypass it, which is the point.
Going deeper
- Effective Java, item 18, whose
InstrumentedHashSetthis lesson borrows, and item 20 on skeletal implementations. - The
java.util.Stackandjava.util.PropertiesJavadoc, each of which apologises for its own superclass. - Gamma et al., Design Patterns: Decorator and Strategy, the two patterns composition gives you for free.
- Guava's
ForwardingSet,ForwardingMapand the rest ofcom.google.common.collect.Forwarding*. - Barbara Liskov, "Data Abstraction and Hierarchy" (1987), the origin of the substitution principle that decides is-a.