Java · lesson 22 of 66
Beginner
17- 1 Introduction to Java
- 2 Setting Up Java
- 3 Data Types in Java
- 4 Operators in Java
- 5 Variables and Operators in Java
- 6 Control Flow in Java
- 7 Arrays in Java
- 8 Functions (Methods) in Java
- 9 Strings in Java
- 10 Methods in Java
- 11 Java OOP — Complete Guide
- 12 Introduction to Object-Oriented Programming in Java
- 13 Encapsulation in Java
- 14 Inheritance in Java
- 15 Polymorphism in Java
- 17 Abstraction in Java
- 18 Error Types in Java
Intermediate
38- 16 Regular Expressions in Java
- 19 Interfaces in Java — A Deep Dive
- 20 Abstract Classes in Java
- 21 Method Overloading in Java
- 22 Composition vs Inheritance in Java
- 23 Generics in Java
- 27 Exception Handling in Java
- 28 Java Streams API
- 29 Collections Framework in Java
- 30 Optional in Java
- 31 File Handling in Java
- 32 Records in Java
- 33 Java 8 Features
- 34 Sealed Classes and Interfaces in Java
- 35 Multithreading in Java
- 37 JDBC — Database Access in Java
- 39 Maven — Java Build and Dependency Management
- 42 Gradle — Modern Java Build Tool
- 44 Unit Testing with JUnit 5 and Mockito
- 45 Logging in Java with SLF4J and Logback
- 49 Spring Boot — Introduction
- 50 Dependency Injection in Spring Boot
- 51 Java Project Ideas
- 52 Building REST APIs with Spring Boot
- 53 Validation in Spring Boot
- 54 Spring Data JPA — Database Access
- 55 Exception Handling in Spring Boot
- 56 Spring Security — Authentication and Authorization
- 57 JWT Authentication in Spring Boot
- 58 Dockerizing Spring Boot Applications
- 59 Java Enums: Beyond Simple Constants
- 60 Inner and Anonymous Classes in Java
- 61 Java Generics: Bounded Types, Wildcards, and Generic Methods
- 62 Java Date/Time API: LocalDate, LocalDateTime, and Duration
- 63 Java NIO.2: Paths, Files, and WatchService
- 64 Java Annotations: Custom Types and Meta-Information
- 65 Java Reflection: Dynamic Type Inspection and Method Invocation
- 66 Java Localization: ResourceBundle and Locale Support
Advanced
11Also 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
| Situation | Choose |
|---|---|
| A true, stable “is-a” relationship | inheritance |
| Reusing code from a class you don’t control | composition (wrap and forward) |
| Changing behaviour at runtime | composition (strategy) |
| Combining optional features | composition (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
- Add a
Lowerdecorator 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.
- Collapse the decorators into one class that holds a
Function<String, String>and chain the steps withandThen.
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.
- Add a
peekOrDefault(T fallback)method toLifoStackthat returns the top element or the fallback when empty, without exposing theDeque.
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”.
- Pass a
TreeSetinto the forwardingCountingSet, 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.