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 extend HashSet 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());
  }
}
 OUTPUT of the above code
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.
Fragile base class problem - HashSet addAll calls the overridden add method so the subclass counts every element twice
This is the fragile base class problem: the subclass is correct only if it knows how the parent is implemented, and that implementation is not part of the parent's documented contract. A future JDK could change 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());
  }
}
 OUTPUT of the above code
Elements in set : 3
Add count       : 3
Sorted elements : [Ethan Hardy, Harry Major, Nancy Smith, Tom Jones]
Add count       : 4

Explanation of the code

  • CountingSet keeps a reference to a Set in the field set, and every method forwards to it. It does not inherit from any set class.
  • When set.addAll() calls add() internally, it calls the wrapped set's add(), 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 HashSet and a TreeSet. 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 the Set interface 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.
Composition over inheritance - subclass explosion with inheritance versus combining small Channel classes with composition
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");
  }
}
 OUTPUT of the above code
[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

  • Channel is the only abstraction. EmailChannel and FlakySmsChannel do the actual sending.
  • Logging and Retrying both implement Channel and hold a Channel. 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 - Extending a class you do not control, only to reuse some of its code - as we did with HashSet - is the case composition is meant for.

Composition in modern Java

  • Make classes final unless 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 Comparator or 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 extended HashSet 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.