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.
The most common OOP mistake in Java is reaching for extends when what you really need is a field. Composition builds objects out of collaborating components; inheritance creates a fixed hierarchy. Understanding the difference leads to more flexible, maintainable designs — and it’s why experienced Java developers almost always choose composition first.
Why Inheritance Can Hurt
Inheritance creates a tight coupling between the child and parent class. When you subclass to reuse code, you also inherit every public method of the parent — even ones that make no sense for the child. This leaks implementation details and breaks encapsulation. The classic Java example is Stack extending Vector: it exposes index-based access methods that allow bypassing the stack’s LIFO contract entirely.
// Stack should be a LIFO structure — push and pop only.
// But inheriting from Vector exposes get/set/remove by index too.
// Users can bypass LIFO order, breaking the abstraction.
public class Stack<T> extends Vector<T> { // BAD — this is the actual java.util.Stack
public T push(T item) { addElement(item); return item; }
public T pop() { return remove(size() - 1); }
}
Stack<String> s = new Stack<>();
s.push("a");
s.push("b");
s.remove(0); // bypasses LIFO — this compiles and runs but violates the contract
Composition fixes this by hiding the internal Vector entirely and exposing only the operations that make sense:
// GOOD — compose with a Deque, expose only the LIFO interface
// The Deque is a private implementation detail; callers can't touch it
public class Stack<T> {
private final Deque<T> storage = new ArrayDeque<>();
public void push(T item) { storage.push(item); }
public T pop() { return storage.pop(); }
public T peek() { return storage.peek(); }
public boolean isEmpty() { return storage.isEmpty(); }
public int size() { return storage.size(); }
// No index access — the LIFO contract is enforced by the API
}
Composition with Dependency Injection
The most practical form of composition in Java is building objects by injecting their collaborators through the constructor. Each collaborator is defined as an interface so implementations can be swapped — in production, in tests, or in different deployment contexts — without changing the class that uses them.
public interface Logger {
void log(String level, String message);
}
public interface MetricsCollector {
void increment(String counter);
void recordTime(String operation, long ms);
}
public interface Cache {
Optional<String> get(String key);
void put(String key, String value, int ttlSeconds);
}
// UserService is composed of collaborators — it doesn't inherit from any of them.
// The benefit: swap the Logger for a test logger, or Cache for a Redis cache,
// without touching UserService at all.
public class UserService {
private final UserRepository repository;
private final Logger logger;
private final MetricsCollector metrics;
private final Cache cache;
// All dependencies are injected — none are created inside this class
public UserService(UserRepository repository, Logger logger,
MetricsCollector metrics, Cache cache) {
this.repository = repository;
this.logger = logger;
this.metrics = metrics;
this.cache = cache;
}
public Optional<User> findById(long id) {
String cacheKey = "user:" + id;
return cache.get(cacheKey).map(json -> {
logger.log("DEBUG", "Cache hit for user " + id);
return User.fromJson(json);
}).or(() -> {
long start = System.currentTimeMillis();
Optional<User> user = repository.findById(id);
metrics.recordTime("user.findById", System.currentTimeMillis() - start);
user.ifPresentOrElse(
u -> {
cache.put(cacheKey, u.toJson(), 300); // cache for 5 minutes
metrics.increment("user.cache.miss");
},
() -> logger.log("WARN", "User not found: " + id)
);
return user;
});
}
}
Decorator Pattern
Decorator adds behaviour to an object without modifying its class. It solves the problem of needing to add combinations of features — like trimming, uppercasing, and prefixing text — without creating a separate subclass for every combination. Each decorator implements the same interface as the object it wraps and adds exactly one responsibility.
public interface TextProcessor {
String process(String text);
}
// Base implementation — does nothing but return the text
public class PlainText implements TextProcessor {
@Override
public String process(String text) { return text; }
}
// Each decorator wraps any TextProcessor and adds one behaviour
public class TrimDecorator implements TextProcessor {
private final TextProcessor wrapped;
public TrimDecorator(TextProcessor wrapped) { this.wrapped = wrapped; }
@Override
public String process(String text) {
return wrapped.process(text.trim()); // trim first, then pass to inner
}
}
public class UpperCaseDecorator implements TextProcessor {
private final TextProcessor wrapped;
public UpperCaseDecorator(TextProcessor wrapped) { this.wrapped = wrapped; }
@Override
public String process(String text) {
return wrapped.process(text).toUpperCase(); // process first, then uppercase
}
}
public class EmojiDecorator implements TextProcessor {
private final TextProcessor wrapped;
private final String emoji;
public EmojiDecorator(TextProcessor wrapped, String emoji) {
this.wrapped = wrapped;
this.emoji = emoji;
}
@Override
public String process(String text) {
return emoji + " " + wrapped.process(text) + " " + emoji;
}
}
// Compose at runtime — nest decorators in any order you need
TextProcessor pipeline = new EmojiDecorator(
new UpperCaseDecorator(
new TrimDecorator(
new PlainText())),
"🚀");
System.out.println(pipeline.process(" hello world ")); // 🚀 HELLO WORLD 🚀
Decorator is how Java’s I/O streams are designed — BufferedReader wraps InputStreamReader which wraps FileInputStream. Each layer adds one capability without knowing or changing the others.
Mixin-Style Composition
Java interfaces with default methods let you mix in small, reusable capabilities without building a class hierarchy. Each interface defines one slice of behaviour; a class picks up whichever slices it needs by implementing multiple interfaces. This gives you composition at the type level.
public interface Identifiable {
// Default method — provides a default ID without requiring implementation
default String getId() {
return getClass().getSimpleName() + "-" + System.identityHashCode(this);
}
}
public interface Timestamped {
LocalDateTime getCreatedAt();
// Default method that builds on the abstract getCreatedAt()
default long ageInSeconds() {
return ChronoUnit.SECONDS.between(getCreatedAt(), LocalDateTime.now());
}
}
public interface SoftDeletable {
boolean isDeleted();
void softDelete();
// Convenience default — derived from isDeleted()
default boolean isActive() { return !isDeleted(); }
}
// Order picks up three capabilities by implementing three focused interfaces
// No shared superclass needed — each concern is completely independent
public class Order implements Identifiable, Timestamped, SoftDeletable {
private final LocalDateTime createdAt = LocalDateTime.now();
private boolean deleted = false;
private final List<String> items;
public Order(List<String> items) { this.items = new ArrayList<>(items); }
@Override public LocalDateTime getCreatedAt() { return createdAt; }
@Override public boolean isDeleted() { return deleted; }
@Override public void softDelete() { this.deleted = true; }
}
Order order = new Order(List.of("Widget", "Gadget"));
System.out.println(order.getId()); // Order-12345678
System.out.println(order.ageInSeconds()); // 0
System.out.println(order.isActive()); // true
order.softDelete();
System.out.println(order.isActive()); // false
When to Use Each
| Situation | Choose |
|---|---|
| ”is-a” with stable, narrow hierarchy | Inheritance |
| Code reuse across unrelated classes | Composition |
| Need to swap behaviour at runtime | Composition |
| Adding capabilities to a class you don’t own | Composition (Decorator) |
| Multiple behaviours from unrelated concerns | Interface mixins |
| Sharing fields and state across subclasses | Abstract class + inheritance |
The practical test: ask “would a subclass instance pass as the parent in ALL contexts without surprises?” If yes, inheritance is appropriate. If you’re just reusing code or combining capabilities, use composition.