Objects that hold their shape · chapter 13 of 33
Composition over inheritance
Chapter 2.3 · Part 2, Objects that hold their shape · about 30 minutes
What you need before this chapter: chapter 1.4, inheritance and polymorphism — extends, super, @Override, and a call resolving to different code depending on an object's real type. You also need chapters 2.1 and 2.2.
When you finish this chapter you will be able to:
- Build a working feature with inheritance, and then produce a real, compiled bug that shows what it costs when the base class changes
- Rebuild the same feature so that a collaborator object, not a superclass, supplies the varying behaviour
- Say when inheritance is the right tool, using a real exception hierarchy from this app's corpus
- Recognise the fragile base class problem, the standard name for the bug you built
1. The obvious move, and it works
A vending machine charges the price on the label. The business wants a promotional version that knocks 20% off every price. Chapter 1.4 gave you the tool that looks made for this: subclass the machine, override the one method that decides a price.
public class VendingMachine {
public long sell(String slotCode, long labelPriceMinor) {
long price = priceOf(slotCode, labelPriceMinor);
System.out.println("charged: " + price);
return price;
}
protected long priceOf(String slotCode, long labelPriceMinor) {
return labelPriceMinor;
}
}
public class PromotionalVendingMachine extends VendingMachine {
@Override
protected long priceOf(String slotCode, long labelPriceMinor) {
return labelPriceMinor * 80 / 100;
}
}
public class Main {
public static void main(String[] args) {
VendingMachine promo = new PromotionalVendingMachine();
promo.sell("B2", 1000);
}
}
javac VendingMachine.java PromotionalVendingMachine.java Main.java
java Main
charged: 800
sell calls priceOf, and because priceOf is overridden, the promotional discount applies without sell itself knowing a promotion exists. This is dynamic dispatch doing exactly what chapter 1.4 said it does, and for one method that the whole class routes every price through, there is nothing wrong with this design.
2. The new requirement, and the bug it exposes
A display screen needs to show a customer the price before they buy, so the machine gains a second method: quote, which answers "how much is this" without charging anything. Whoever adds it works from the base class's own field of a long labelPriceMinor, because that is what a label is, and does not think to route it through priceOf the way sell does.
public class VendingMachine {
public long sell(String slotCode, long labelPriceMinor) {
long price = priceOf(slotCode, labelPriceMinor);
System.out.println("charged: " + price);
return price;
}
/** Added later, for a "how much is this" display. Nobody touched priceOf() to add it. */
public long quote(String slotCode, long labelPriceMinor) {
return labelPriceMinor;
}
protected long priceOf(String slotCode, long labelPriceMinor) {
return labelPriceMinor;
}
}
PromotionalVendingMachine is unchanged. Nobody edited it, and nobody needed to for it to compile.
public class Main {
public static void main(String[] args) {
VendingMachine promo = new PromotionalVendingMachine();
long charged = promo.sell("B2", 1000);
long quoted = promo.quote("B2", 1000);
System.out.println("charged: " + charged);
System.out.println("quoted: " + quoted);
}
}
charged: 800
charged: 800
quoted: 1000
The display would show a customer 1000 and then charge them 800. The two numbers disagree about the same sale. Nothing here is a compile error, and nothing is even wrong with quote read on its own; a fresh reader would call it correct. The bug is that PromotionalVendingMachine only overrode the one seam that existed on the day it was written. VendingMachine grew a second way to compute a price, and nothing forced that new method to go through the same seam. This is not a hypothetical maintenance worry. You just watched it happen, in eleven lines, without touching the subclass at all.
This has a name: the fragile base class problem. A subclass is not really contracted to the base class's public method signatures alone. It depends on how the base class's methods call each other internally. That calling pattern is not part of any interface, and the compiler does not check it. It is free to change the moment someone edits the base class for a reason that has nothing to do with your subclass.
3. The move: ask an object instead of overriding a method
Give pricing its own type, held by the machine as a field rather than as a method the machine happens to inherit.
public interface PricingPolicy {
long priceMinor(String slotCode, long labelPriceMinor);
}
public final class ListPricing implements PricingPolicy {
@Override
public long priceMinor(String slotCode, long labelPriceMinor) {
return labelPriceMinor;
}
}
public final class PromotionalPricing implements PricingPolicy {
private final PricingPolicy shelfPrices;
public PromotionalPricing(PricingPolicy shelfPrices) {
this.shelfPrices = shelfPrices;
}
@Override
public long priceMinor(String slotCode, long labelPriceMinor) {
return shelfPrices.priceMinor(slotCode, labelPriceMinor) * 80 / 100;
}
}
public final class VendingMachine {
private final PricingPolicy pricing;
public VendingMachine(PricingPolicy pricing) {
this.pricing = pricing;
}
public long sell(String slotCode, long labelPriceMinor) {
long price = pricing.priceMinor(slotCode, labelPriceMinor);
System.out.println("charged: " + price);
return price;
}
public long quote(String slotCode, long labelPriceMinor) {
return pricing.priceMinor(slotCode, labelPriceMinor);
}
}
public class Main {
public static void main(String[] args) {
VendingMachine promo = new VendingMachine(new PromotionalPricing(new ListPricing()));
long charged = promo.sell("B2", 1000);
long quoted = promo.quote("B2", 1000);
System.out.println("charged: " + charged);
System.out.println("quoted: " + quoted);
}
}
charged: 800
charged: 800
quoted: 800
There is no longer a base class to inherit from, and no seam that a future method might forget to call. sell and quote both ask the same pricing object the same question, so they cannot disagree. Not because whoever wrote quote remembered to be careful, but because there is only one place a price is ever computed. VendingMachine has a PricingPolicy. It does not is a promotional variant of itself.
This is what "favour composition over inheritance" means, stated as code rather than as advice. When a class needs to behave differently in one respect, give it a collaborator object that supplies that one behaviour, instead of subclassing the whole thing to override a piece of it. `corpus/vending- machine's real VendingMachine is built exactly this way. It takes a SlotRack, a CoinPurse`, a PricingPolicy, a ChangeMaker, and a TransitionTable in its constructor, and it has no superclass of its own beyond Object. Every one of those five things can vary independently, and none of them requires a new subclass of VendingMachine to vary.
That independence is worth naming directly, because it is also where inheritance would have gone on failing. Suppose the business later wants a members' lounge machine: promotional pricing and a members-only slot unlocked at once, then later a members-only machine with no promotion. Inheritance answers this by writing one subclass per combination: PromotionalVendingMachine, MemberVendingMachine, MemberPromotionalVendingMachine, and a fourth for neither. Two independent toggles cost four classes, and a third toggle would cost eight. VendingMachine(pricing, access) costs nothing extra: a members' lounge machine is one line combining two objects that already exist.
4. Where inheritance is still the right call
None of this makes inheritance wrong. corpus/vending-machine's own exception hierarchy uses it on purpose, and it is worth seeing why it survives the same scrutiny that just broke PromotionalVendingMachine.
public abstract class VendingMachineException extends RuntimeException {
protected VendingMachineException(String message) {
super(message);
}
}
public class SoldOutException extends VendingMachineException {
private final String slotCode;
public SoldOutException(String slotCode) {
super("slot " + slotCode + " is sold out");
this.slotCode = slotCode;
}
public String slotCode() {
return slotCode;
}
}
Five refusal types share this one root, so a caller who only wants to catch "something went wrong with a sale" can catch VendingMachineException once instead of five separate types. Nothing here calls back into an overridable method the way sell called priceOf. SoldOutException does not override behaviour that VendingMachineException invokes on itself; it only adds a field and a constructor. There is no second base method that could grow later and bypass a subclass's override, because there is no override at all. The contract names exactly five refusals and states that no sixth kind should be constructed through this root. That is a genuine is-a relationship on a closed set of variants, and inheritance models a closed set of variants better than an interface with five separate implementations would.
corpus/file-system makes the same call with sealed interface Node permits FileNode, DirectoryNode. A sealed type is inheritance with the set of subtypes fixed at compile time. A switch over "which kind of node is this" is then checked exhaustively by javac. Adding a third kind of node turns every such switch into a compile error, until it is handled. The test that separates this from the vending machine mistake: does anything in the base type call back into a method the subtype overrides? VendingMachineException and Node do not. VendingMachine.sell did. That callback is where the fragility lives, not in inheritance itself.
Your turn
corpus/logger has Level, an enum with five constants (DEBUG, INFO, WARN, ERROR, FATAL) and a method atLeast(Level other) that every constant shares. Every Java enum implicitly extends java.lang.Enum. Explain, in a sentence or two, why this is the same safe shape as VendingMachineException's hierarchy rather than the same risky shape as VendingMachine's.
The answer. Enum never calls back into a method a particular constant overrides as part of its own internal machinery, the way sell called priceOf. The methods Level inherits from Enum (name(), ordinal(), compareTo()) are settled once, by the language. They are not seams a future version of Enum will reroute through a different internal method. The set of constants is also closed the same way Node's permitted types are: nothing outside Level.java can add a sixth severity. Both properties, no dangerous self-call and a closed set, are what made VendingMachineException safe, and they hold here for the same reason.
Going deeper
The fragile base class problem has a famous instance in the Java standard library itself. It is worth seeing once, because it shows the bug arising from code you would never suspect: java.util.HashSet.
import java.util.Collection;
import java.util.HashSet;
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(java.util.List.of("KA-01", "MH-12", "TN-22"));
System.out.println("elements added: 3");
System.out.println("addCount reports: " + s.getAddCount());
}
}
javac InstrumentedHashSet.java
java InstrumentedHashSet
elements added: 3
addCount reports: 6
Three elements were added, and addCount reports six. HashSet's superclass, AbstractCollection, implements addAll by calling add once per element, and HashSet never overrode that fact away. addAll(3 elements) adds 3 to addCount for itself, then calls add three times, and each of those calls adds 1 more: 3 + 3 = 6. Nothing about HashSet's documented contract promised addAll would or would not call add internally. That is an implementation detail of a class in the standard library, and InstrumentedHashSet depended on it anyway, because subclassing gives you no way not to. This is the exact shape of bug you built in section 2, living in a class millions of programs import without a second thought. The fix is the same one this chapter already walked through: wrap a Set as a field and delegate to it, rather than extend it.
Why this matters in an interview
An interviewer who asks "why composition over inheritance" is not asking you to recite a rule. They want to see you name the actual failure: a base class's internal calling pattern is not part of its contract. A subclass that depends on it is coupled to an implementation detail that can change without any signature changing. Being able to produce the quote/sell mismatch, or cite HashSet.addAll, on the spot is what separates "I know the slogan" from "I know why the slogan is true."
Just as important is the second half of this chapter. Inheritance is not banned. VendingMachineException never overrides a base method. Reaching for composition there anyway is the mistake in the other direction: a PricingPolicy-style interface with exactly one class behind it, and no second one in sight.
Next: chapter 2.4, Single responsibility, and how to test for it. The question turns from "what is this object made of" to "how many reasons does this class have to change."
← 2.2 Immutability, and the bugs it deletes · All chapters · 2.4 Single responsibility, and how to test for it →