Skip to main content
Java · lesson 22 of 66
Open the Java page →
Java intermediate Lesson 22 of 66

Also part of: Low-Level Design

Composition vs Inheritance in Java

Learn when to favour composition over inheritance, how to use the Decorator and Strategy patterns, and how to build flexible, loosely coupled Java classes.

By SkillByExample · Updated

The most common OOP mistake in Java is reaching for extends when what you need is a field. Inheritance welds a subclass to its parent’s whole public API and internal behaviour; composition builds an object from collaborators it holds and calls. Each example is one file run with the JDK 21 source launcher (java File.java).

Inheritance leaks the parent’s API

java.util.Stack extends Vector, so every Vector method is also a Stack method — including ones that ignore last-in-first-out order.

import java.util.Stack;

public class LeakyStack {
    public static void main(String[] args) {
        Stack<String> s = new Stack<>();
        s.push("a");
        s.push("b");
        s.push("c");
        s.remove(0);          // a Vector method: removes the BOTTOM element
        s.add(0, "z");        // and inserts at the bottom
        System.out.println(s + " pop=" + s.pop());
    }
}
$ java LeakyStack.java
[z, b, c] pop=c

Nothing stopped the caller from reaching under the stack. The type says “stack”, but the API says “resizable array”.

Composition exposes only what you choose

Hold the storage in a private field and forward only the operations that make sense.

import java.util.ArrayDeque;
import java.util.Deque;

public class SafeStack {
    public static void main(String[] args) {
        LifoStack<String> s = new LifoStack<>();
        s.push("a");
        s.push("b");
        System.out.println(s.pop() + " " + s.size());
        s.remove(0);
    }
}

class LifoStack<T> {
    private final Deque<T> storage = new ArrayDeque<>();
    void push(T item) { storage.push(item); }
    T pop()           { return storage.pop(); }
    int size()        { return storage.size(); }
}
$ java SafeStack.java
SafeStack.java:10: error: cannot find symbol
        s.remove(0);
         ^
  symbol:   method remove(int)
  location: variable s of type LifoStack<String>
1 error
error: compilation failed

The misuse is now a compile error. And because storage is private, swapping ArrayDeque for another Deque later breaks no caller.

The fragile base class problem

Subclassing also ties you to the parent’s implementation. Here a subclass of HashSet counts how many elements were ever added:

import java.util.HashSet;
import java.util.List;

public class FragileBase {
    public static void main(String[] args) {
        CountingSet<String> set = new CountingSet<>();
        set.addAll(List.of("a", "b", "c"));
        System.out.println("added = " + set.added);
    }
}

class CountingSet<E> extends HashSet<E> {
    int added = 0;
    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(java.util.Collection<? extends E> c) {
        added += c.size();
        return super.addAll(c);
    }
}
$ java FragileBase.java
added = 6

Three elements, but the count is 6: HashSet.addAll happens to call add for each element, so every element is counted twice. That self-call is an undocumented detail of HashSet, yet the subclass depends on it.

The composed version wraps any Set and forwards to it, so the wrapped set’s internals cannot reach back into the counter:

import java.util.*;

public class ForwardingFix {
    public static void main(String[] args) {
        CountingSet<String> set = new CountingSet<>(new HashSet<>());
        set.addAll(List.of("a", "b", "c"));
        set.add("d");
        System.out.println("added = " + set.added + ", size = " + set.size());
    }
}

class CountingSet<E> {
    private final Set<E> inner;
    int added = 0;
    CountingSet(Set<E> inner) { this.inner = inner; }
    boolean add(E e)                         { added++; return inner.add(e); }
    boolean addAll(Collection<? extends E> c) { added += c.size(); return inner.addAll(c); }
    int size()                               { return inner.size(); }
}
$ java ForwardingFix.java
added = 4, size = 4

inner.addAll calls inner.add, not CountingSet.add, so each element is counted once. As a bonus, CountingSet now works with a TreeSet or LinkedHashSet too — pass one in.

Strategy: swap behaviour at runtime

Composition lets an object change behaviour by swapping a collaborator, which inheritance cannot do once an object is created.

import java.util.function.DoubleUnaryOperator;

public class Pricing {
    public static void main(String[] args) {
        Checkout c = new Checkout(price -> price);
        System.out.println(c.total(100));
        c.setDiscount(price -> price * 0.9);
        System.out.println(c.total(100));
        c.setDiscount(price -> Math.max(0, price - 25));
        System.out.println(c.total(100));
    }
}

class Checkout {
    private DoubleUnaryOperator discount;
    Checkout(DoubleUnaryOperator discount) { this.discount = discount; }
    void setDiscount(DoubleUnaryOperator d) { this.discount = d; }
    double total(double price) { return discount.applyAsDouble(price); }
}
$ java Pricing.java
100.0
90.0
75.0

One Checkout object, three behaviours. With inheritance you would need TenPercentCheckout, FlatDiscountCheckout, … and a new object to change pricing.

Decorator: stack behaviours by wrapping

A decorator implements the same interface as the object it wraps and adds one behaviour before or after delegating. Wrappers nest, so features combine without a subclass per combination.

public class Decorators {
    public static void main(String[] args) {
        TextProcessor pipeline = new Bracket(new Upper(new Trim(new Plain())));
        System.out.println(pipeline.process("  hello world  "));
        System.out.println(new Upper(new Bracket(new Plain())).process("  hi "));
    }
}

interface TextProcessor { String process(String text); }

class Plain implements TextProcessor {
    public String process(String text) { return text; }
}
class Trim implements TextProcessor {
    private final TextProcessor inner;
    Trim(TextProcessor inner) { this.inner = inner; }
    public String process(String text) { return inner.process(text.trim()); }
}
class Upper implements TextProcessor {
    private final TextProcessor inner;
    Upper(TextProcessor inner) { this.inner = inner; }
    public String process(String text) { return inner.process(text).toUpperCase(); }
}
class Bracket implements TextProcessor {
    private final TextProcessor inner;
    Bracket(TextProcessor inner) { this.inner = inner; }
    public String process(String text) { return "[" + inner.process(text) + "]"; }
}
$ java Decorators.java
[HELLO WORLD]
[  HI ]

The second pipeline has no Trim, so the spaces survive inside the brackets. Java’s I/O is built this way: new BufferedReader(new InputStreamReader(new FileInputStream(...))) stacks three decorators.

When to use each

SituationChoose
A true, stable “is-a” relationshipinheritance
Reusing code from a class you don’t controlcomposition (wrap and forward)
Changing behaviour at runtimecomposition (strategy)
Combining optional featurescomposition (decorator)

The test: would a subclass instance work as the parent everywhere the parent is used, without surprises? If not — or if you only want its code — compose.

Practice

  1. Add a Lower decorator and build a pipeline that trims, lowercases, then brackets " MiXeD ".
Solution
public class LowerDemo {
    public static void main(String[] args) {
        TextProcessor p = new Bracket(new Lower(new Trim(new Plain())));
        System.out.println(p.process("  MiXeD  "));
    }
}

interface TextProcessor { String process(String text); }
class Plain implements TextProcessor { public String process(String t) { return t; } }
class Trim implements TextProcessor {
    private final TextProcessor in; Trim(TextProcessor in) { this.in = in; }
    public String process(String t) { return in.process(t.trim()); }
}
class Lower implements TextProcessor {
    private final TextProcessor in; Lower(TextProcessor in) { this.in = in; }
    public String process(String t) { return in.process(t).toLowerCase(); }
}
class Bracket implements TextProcessor {
    private final TextProcessor in; Bracket(TextProcessor in) { this.in = in; }
    public String process(String t) { return "[" + in.process(t) + "]"; }
}
$ java LowerDemo.java
[mixed]

No existing class changed; the new behaviour is one more wrapper.

  1. Collapse the decorators into one class that holds a Function<String, String> and chain the steps with andThen.
Solution
import java.util.function.Function;

public class FunctionPipeline {
    public static void main(String[] args) {
        Function<String, String> trim = String::trim;
        Function<String, String> pipeline = trim.andThen(String::toUpperCase).andThen(s -> "[" + s + "]");
        System.out.println(pipeline.apply("  hello world  "));
    }
}
$ java FunctionPipeline.java
[HELLO WORLD]

Lambdas remove the boilerplate classes. You lose named types, so a decorator that needs its own state or several methods is still clearer as a class.

  1. Add a peekOrDefault(T fallback) method to LifoStack that returns the top element or the fallback when empty, without exposing the Deque.
Solution
import java.util.ArrayDeque;
import java.util.Deque;

public class PeekDemo {
    public static void main(String[] args) {
        LifoStack<String> s = new LifoStack<>();
        System.out.println(s.peekOrDefault("empty"));
        s.push("a");
        System.out.println(s.peekOrDefault("empty"));
    }
}

class LifoStack<T> {
    private final Deque<T> storage = new ArrayDeque<>();
    void push(T item) { storage.push(item); }
    T pop()           { return storage.pop(); }
    T peekOrDefault(T fallback) {
        T top = storage.peek();
        return top != null ? top : fallback;
    }
}
$ java PeekDemo.java
empty
a

ArrayDeque.peek() returns null when empty (it does not allow null elements), so null reliably means “nothing there”.

  1. Pass a TreeSet into the forwarding CountingSet, add "b", "a", "b", and print both the count and the set’s contents.
Solution
import java.util.*;

public class CountingTree {
    public static void main(String[] args) {
        TreeSet<String> tree = new TreeSet<>();
        CountingSet<String> set = new CountingSet<>(tree);
        for (String s : List.of("b", "a", "b")) set.add(s);
        System.out.println("added = " + set.added + ", contents = " + tree);
    }
}

class CountingSet<E> {
    private final Set<E> inner;
    int added = 0;
    CountingSet(Set<E> inner) { this.inner = inner; }
    boolean add(E e) { added++; return inner.add(e); }
}
$ java CountingTree.java
added = 3, contents = [a, b]

added counts attempts (3); the set holds unique elements (2), sorted because the wrapped set is a TreeSet. The wrapper never had to know which Set it was given.

Frequently Asked Questions

What does 'favour composition over inheritance' mean?
It is a design principle (from the Gang of Four book) that recommends building complex behaviour by combining simple objects rather than by extending class hierarchies. Composition is more flexible because you can swap components at runtime; inheritance bakes in the relationship at compile time.
Is inheritance ever the right choice?
Yes — when a true, stable 'is-a' relationship exists and the child class genuinely IS a specialisation of the parent. If you are only reusing code, prefer composition. If you are modelling a real subtype, inheritance is appropriate.
What is the Decorator pattern?
Decorator is a structural design pattern that uses composition to add behaviour to an object dynamically, without modifying its class. You wrap an object in a decorator that implements the same interface and adds functionality before or after delegating to the wrapped object.
What is the fragile base class problem?
It is when a subclass breaks because of an internal detail of its superclass, such as one public method calling another overridable method. The subclass depends on how the parent is implemented, not just on what it promises, so a harmless-looking change in the parent can silently break every subclass.