Composition over Inheritance in Java with Examples
Introduction
This tutorial explains the design principle composition over inheritance, with Java examples. We will start with what inheritance and composition mean and how they differ. We will then see two problems that inheritance causes - a subclass that silently breaks because of a detail in its parent, and an explosion of subclasses when behaviours are combined - and fix both with composition. Lastly, we will look at when inheritance is still the right choice, and what modern Java offers to help.Inheritance and composition - is-a versus has-a
Both are ways to reuse code. Inheritance creates an is-a relationship: a subclass is a kind of its parent, and gets the parent's fields and methods. Composition creates a has-a relationship: a class has a reference to another object, and calls it to get work done (this is called delegation).The principle "favour object composition over class inheritance" was stated in the Gang of Four book Design Patterns (1994), and Joshua Bloch's Effective Java devotes an item to it. It does not say "never inherit"; it says that when either would work, composition is usually the safer choice. The rest of this tutorial shows why.
Problem 1 - the fragile base class
Inheritance gives a subclass access to its parent's internals, and also makes it depend on them. Let us extendHashSet to count how many elements were ever added. We override both add() and addAll() so that every way of adding is counted.
Java code extending HashSet to count added elements
//InheritanceBreaks.java
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
public class InheritanceBreaks {
// Counts how many elements were ever added - by extending HashSet
@SuppressWarnings("serial")
static class CountingHashSet<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);
}
int getAddCount() { return addCount; }
}
public static void main(String[] args) {
CountingHashSet<String> names = new CountingHashSet<>();
names.addAll(List.of("Tom Jones", "Harry Major", "Ethan Hardy"));
System.out.println("Elements in set : " + names.size());
System.out.println("Add count : " + names.getAddCount());
}
}
Elements in set : 3 Add count : 6
Why the count is wrong
We added three names, but the count says six. The reason is not in our code at all.HashSet inherits its addAll() from AbstractCollection, which loops over the collection and calls add() for each element - and that call goes to our overridden add(), which counts each element a second time.
addAll() to stop calling add(), and a "fix" that removes our addAll() override would then undercount instead.
The fix - wrap the set instead of extending it
With composition, our class has a set and forwards calls to it. It no longer overrides anything, so nothing inside the set can call back into our code.Java code counting added elements by composition
//CompositionWorks.java
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
public class CompositionWorks {
// Has a Set, and forwards calls to it
static class CountingSet<E> {
private final Set<E> set;
private int addCount = 0;
CountingSet(Set<E> set) { this.set = set; }
boolean add(E e) {
addCount++;
return set.add(e);
}
boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
int size() { return set.size(); }
int getAddCount() { return addCount; }
Set<E> view() { return Collections.unmodifiableSet(set); }
}
public static void main(String[] args) {
List<String> team = List.of("Tom Jones", "Harry Major", "Ethan Hardy");
CountingSet<String> hashed = new CountingSet<>(new HashSet<>());
hashed.addAll(team);
System.out.println("Elements in set : " + hashed.size());
System.out.println("Add count : " + hashed.getAddCount());
// The same counting logic works with any Set implementation
CountingSet<String> sorted = new CountingSet<>(new TreeSet<>());
sorted.addAll(team);
sorted.add("Nancy Smith");
System.out.println("Sorted elements : " + sorted.view());
System.out.println("Add count : " + sorted.getAddCount());
}
}
Elements in set : 3 Add count : 3 Sorted elements : [Ethan Hardy, Harry Major, Nancy Smith, Tom Jones] Add count : 4
Explanation of the code
CountingSetkeeps a reference to aSetin the fieldset, and every method forwards to it. It does not inherit from any set class.- When
set.addAll()callsadd()internally, it calls the wrapped set'sadd(), not ours, so each element is counted once. The count is right regardless of how the set is implemented. - The set to wrap is passed into the constructor, so the same class counts additions to a
HashSetand aTreeSet. The inheritance version would need a second subclass,CountingTreeSet. view()returns a read-only view, so callers cannot bypass the counter by adding to the set directly.- NOTE - if callers need a full
Set, the class can implement theSetinterface and forward all of its methods. That is more typing, but it is still composition: we implement an interface, and reuse the implementation through a field.
Problem 2 - the subclass explosion
The second problem appears when behaviours need to be combined. Suppose payslip notifications can go by email or SMS, and each can optionally be logged, retried, or both. With inheritance, every combination becomes a class -EmailWithRetry, SmsWithRetryAndLogging and so on. With composition, each behaviour is one small class that wraps another.
Java code combining notification behaviours by composition
//NotifierComposition.java
public class NotifierComposition {
interface Channel { void send(String to, String message); }
static class EmailChannel implements Channel {
public void send(String to, String message) {
System.out.println("EMAIL to " + to + ": " + message);
}
}
// An SMS gateway that fails on its first call, to show retrying
static class FlakySmsChannel implements Channel {
private int calls = 0;
public void send(String to, String message) {
if (++calls == 1) throw new IllegalStateException("gateway timeout");
System.out.println("SMS to " + to + ": " + message);
}
}
// Behaviours: each one is a Channel and wraps a Channel
static class Logging implements Channel {
private final Channel inner;
Logging(Channel inner) { this.inner = inner; }
public void send(String to, String message) {
System.out.println("[log] sending to " + to);
inner.send(to, message);
}
}
static class Retrying implements Channel {
private final Channel inner;
private final int attempts;
Retrying(Channel inner, int attempts) { this.inner = inner; this.attempts = attempts; }
public void send(String to, String message) {
for (int i = 1; ; i++) {
try {
inner.send(to, message);
return;
} catch (IllegalStateException e) {
System.out.println("[retry] attempt " + i + " failed: " + e.getMessage());
if (i == attempts) throw e;
}
}
}
}
public static void main(String[] args) {
Channel email = new Logging(new EmailChannel());
email.send("tom.jones@example.com", "Your payslip is ready");
Channel sms = new Retrying(new Logging(new FlakySmsChannel()), 3);
sms.send("+44 7700 900123", "Your payslip is ready");
}
}
[log] sending to tom.jones@example.com EMAIL to tom.jones@example.com: Your payslip is ready [log] sending to +44 7700 900123 [retry] attempt 1 failed: gateway timeout [log] sending to +44 7700 900123 SMS to +44 7700 900123: Your payslip is ready
Explanation of the code
Channelis the only abstraction.EmailChannelandFlakySmsChanneldo the actual sending.LoggingandRetryingboth implementChanneland hold aChannel. Each adds its behaviour and delegates the rest.- Combinations are built at runtime by nesting:
new Retrying(new Logging(new FlakySmsChannel()), 3). The first attempt fails and is logged, and the second one succeeds. - Two channels and two behaviours need four classes. Adding a third behaviour, such as rate limiting, is one more class - not one more class per channel per combination.
- This structure is the Decorator design patternClick to read: Decorator design pattern. Passing a behaviour in rather than inheriting it is also the idea behind the Strategy design patternClick to read: Strategy design pattern.
Benefits of composition
- Encapsulation is kept: the wrapper sees only the public interface of the object it uses, so the object's internals can change freely.
- Behaviour can change at runtime: inheritance is fixed when the class is compiled; a composed object can be given a different collaborator at any time.
- Combinations do not multiply classes: behaviours are combined by nesting objects, not by writing a subclass per combination.
- Testing is easier: a collaborator passed in through the constructor can be replaced by a fake in tests.
- No single-inheritance limit: a Java class extends one class, but it can hold any number of collaborators.
When inheritance is the right choice
Inheritance is still the right tool when all of these hold -- It is a true is-a relationship, and every subclass can stand in for the parent without surprising its callers - the Liskov Substitution PrincipleClick to read: Liskov Substitution Principle.
- The parent was designed for extension: it documents which methods may be overridden and what calls what. The Template Method patternClick to read: Template Method pattern is inheritance used deliberately in this way.
- Parent and subclass are maintained together, in the same package or module, by the same team.
HashSet - is the case composition is meant for.
Composition in modern Java
- Make classes
finalunless they are designed for extension, so no one inherits from them by accident. Records and enums are final already. - Use interfaces for types, and share code through composition or default methods rather than abstract base classes.
- Use lambdas for small behaviours: a one-method collaborator, such as a
Comparatoror a formatter, can be passed in as a lambda instead of a subclass. - Use sealed interfaces when a type has a fixed set of variants, so the hierarchy stays closed and the compiler knows all the cases.
Summary
In this tutorial we first saw the difference between inheritance (is-a) and composition (has-a). Next we extendedHashSet to count additions and got a wrong count, because the parent's addAll() calls the overridden add() - the fragile base class problem - and fixed it by wrapping a set instead. We then saw how combining behaviours with subclasses multiplies classes, and how small wrapper classes combine the same behaviours at runtime. We then listed the benefits of composition. Lastly, we saw when inheritance is still the right choice, and how final classes, interfaces, lambdas and sealed types help in modern Java.Programming Principles Tutorials
SOLID Principles in Java: Overview with ExamplesClick to read tutorial: SOLID Principles in Java: Overview with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC Law of Demeter in Java Explained with ExamplesClick to read tutorial: Law of Demeter in Java Explained with Examples Tell, Don't Ask Principle Explained with Java ExamplesClick to read tutorial: Tell, Don't Ask Principle Explained with Java Examples Separation of Concerns Explained with Java ExamplesClick to read tutorial: Separation of Concerns Explained with Java Examples
SOLID Principles in Java: Overview with ExamplesClick to read tutorial: SOLID Principles in Java: Overview with Examples Coupling and Cohesion Explained with Java ExamplesClick to read tutorial: Coupling and Cohesion Explained with Java Examples DRY, KISS and YAGNI Principles with Java ExamplesClick to read tutorial: DRY, KISS and YAGNI Principles with Java Examples Dependency Injection vs Dependency Inversion vs IoCClick to read tutorial: Dependency Injection vs Dependency Inversion vs IoC Law of Demeter in Java Explained with ExamplesClick to read tutorial: Law of Demeter in Java Explained with Examples Tell, Don't Ask Principle Explained with Java ExamplesClick to read tutorial: Tell, Don't Ask Principle Explained with Java Examples Separation of Concerns Explained with Java ExamplesClick to read tutorial: Separation of Concerns Explained with Java Examples