Composition over Inheritance in Java
Learn why has-a relationships outperform is-a for flexible, loosely-coupled Java design.
Learn why has-a relationships outperform is-a for flexible, loosely-coupled Java design.
Composition over Inheritance in Java
Prefer “has-a” over “is-a”. Instead of inheriting behavior, compose objects with the behaviors you need. This creates flexible, loosely-coupled designs that are easier to test and evolve.
Introduction
The “composition over inheritance” principle recommends “has-a” relationships over “is-a” relationships — objects that contain other objects to obtain behavior, rather than classes that inherit behavior from parent classes. This is not a dismissal of inheritance; it is a response to the specific failure modes that inheritance creates when misused. The fragile base class problem is the core issue: subclasses that depend on parent implementation details break when the parent changes, even when the change seems unrelated to the subclass’s overrides. A concrete Stack extending ArrayList breaks because ArrayList exposes methods (like random access by index) that make no sense for a stack’s LIFO semantics.
Composition solves these problems through delegation. A class depends on an interface contract rather than a concrete implementation. Collaborators are injected via constructors — dependency injection by default — and can be swapped at runtime. This makes testing straightforward: you mock the interface, not the parent class. It also enables patterns that inheritance cannot support at all: runtime behavior addition via the Decorator pattern, where an encryption layer wraps a file writer without the writer knowing it exists; runtime behavior selection via the Strategy pattern, where a pricing engine can swap discount strategies without recompilation.
This post covers the genuine use cases for inheritance (true “is-a” with a stable parent), the patterns composition enables — delegation, Decorator, Strategy — and the failure scenarios that motivate the preference: force-fitting inheritance onto Stack-ArrayList, tight coupling via inheritance chains that break on parent refactoring, and the exposure of inherited methods that violate the contained object’s invariants. By the end, you’ll know when to reach for composition and when inheritance actually fits better.
When to Use
Use composition when:
- A “has-a” relationship better describes the model — a
Carhas anEngine, not is anEngine - You need behavior from multiple sources — Java single inheritance won’t allow extending multiple classes
- You want runtime flexibility — swap implementations without changing the class
- You want loose coupling — classes depend on interfaces, not concrete implementations
- You need to hide implementation details — wrap and delegate, don’t expose
// Composition: Car HAS-A Engine
public class Engine {
public void start() { System.out.println("Engine starting"); }
public void stop() { System.out.println("Engine stopping"); }
}
public class Car {
private final Engine engine; // Has-a relationship
public Car(Engine engine) {
this.engine = engine;
}
public void drive() {
engine.start();
System.out.println("Car driving");
engine.stop();
}
}
When Not to Use
Don’t use composition when:
- True “is-a” relationship exists —
Dogis anAnimal, inheritance is appropriate - You need to override parent behavior — inheritance lets you override methods
- Shared immutable state — inheritance with
finalfields may be cleaner - Simple use cases — if inheritance is simpler and clear, use it (but be cautious)
// Valid inheritance: Dog truly is an Animal
public class Animal {
protected String name;
public void eat() { }
}
public class Dog extends Animal { // Dog IS an Animal
private String breed;
public void bark() { }
}
Dog dog = new Dog();
dog.eat(); // Inherited from Animal — appropriate here
Inheritance — Mermaid Diagram
flowchart TD
A1[Animal] --> B1[Dog]
A1 --> C1[Cat]
B1 --> D1[Terrier]
Composition — Mermaid Diagram
flowchart TD
A2[Order] --> B2[Payment]
A2 --> C2[Inventory]
A2 --> D2[Shipping]
Failure Scenarios
1. Force-Fitting Inheritance
// WRONG: Stack is not an ArrayList
public class Stack extends ArrayList {
public void push(Object item) { add(item); }
public Object pop() { return remove(size() - 1); }
}
// Problems:
// - ArrayList has methods like get(index), remove(index) that Stack shouldn't have
// - Invariants differ: ArrayList allows any index access, Stack only LIFO
// - Exposes 20+ methods that make no sense for a Stack
// CORRECT: Composition
public class Stack {
private final List<Object> items = new ArrayList<>();
public void push(Object item) { items.add(item); }
public Object pop() {
if (items.isEmpty()) throw new IllegalStateException("Empty");
return items.remove(items.size() - 1);
}
}
2. Fragile Base Class Problem
public class Base {
public List<String> getItems() {
return items; // Returns mutable list — subclasses can break this!
}
protected List<String> items = new ArrayList<>();
}
public class Derived extends Base {
public void addItem(String item) {
items.add(item); // Modifies the list from Base
}
}
// If Base changes to return defensive copy, Derived may break
// If Base changes internals, Derived behavior may change unexpectedly
3. Tight Coupling via Inheritance
Inheritance locks a class into its parent’s behavior contract. When you extend HashMap, you inherit every public and protected method — containsValue, remove(Object key, Object value), clear, putAll — whether your subclass needs them or not. The compiler enforces nothing about semantic compatibility. A HashMapExtended that only intended to add logging to put still carries all the other methods that clients can call, potentially violating invariants your subclass depends on.
More critically, HashMap’s internal representation — the bucket array, the tree/cargo nodes in Java 8+, the hash distribution logic — isn’t guaranteed by any interface contract. It changes between JVM versions and vendors. When HashMap switched from linked-list buckets to red-black tree buckets in Java 8, subclasses that relied on iteration order or bucket internals broke silently. There’s no way to swap HashMap for LinkedHashMap or TreeMap in a subclass without rewriting the class. Composition solves this by depending only on the Map interface, so any Map implementation can be injected.
// Inheritance creates tight coupling — any HashMap change propagates down
public class HashMapExtended extends HashMap {
// Problem: exposes all 30+ HashMap methods to callers
// Problem: relies on HashMap bucket structure not in any contract
// Problem: cannot switch to LinkedHashMap without rewriting the class
// Problem: internal refactoring in HashMap can silently break this class
}
The fix is composition with the Map interface. A wrapper that accepts any Map implementation and delegates to it avoids all of these problems. The wrapper only exposes the methods it actually needs, and the underlying map can be swapped for testing (HashMap), ordering (LinkedHashMap), or sorting (TreeMap) without changing the wrapper’s code.
Trade-off Table
| Aspect | Inheritance | Composition |
|---|---|---|
| Coupling | Tight — subclass depends on parent internals | Loose — depends on interface/abstract type |
| Flexibility | Fixed at compile-time | Can swap implementations at runtime |
| Reuse | Inherited code runs in subclass context | Delegated code runs in wrapper context |
| Testing | Hard to mock parent class | Easy to mock collaborator |
| Hierarchy depth | Deep hierarchies problematic | Shallow, flexible structures |
Code Snippets
Delegation / Composition with Interface
public interface Logger {
void log(String message);
}
public class ConsoleLogger implements Logger {
@Override
public void log(String message) {
System.out.println("[CONSOLE] " + message);
}
}
public class FileLogger implements Logger {
@Override
public void log(String message) {
// Write to file
System.out.println("[FILE] " + message);
}
}
public class Service {
private final Logger logger; // Composed, not inherited
public Service(Logger logger) {
this.logger = logger;
}
public void doWork() {
logger.log("Work started");
// Do work
logger.log("Work completed");
}
}
Decorator Pattern (Runtime Behavior Addition)
public interface DataSource {
void write(String data);
String read();
}
public class FileDataSource implements DataSource {
private final String filename;
public FileDataSource(String filename) {
this.filename = filename;
}
@Override
public void write(String data) {
Files.writeString(Path.of(filename), data);
}
@Override
public String read() {
return Files.readString(Path.of(filename));
}
}
// Decorator adds encryption without changing FileDataSource
public class EncryptionDataSource implements DataSource {
private final DataSource wrapped;
public EncryptionDataSource(DataSource wrapped) {
this.wrapped = wrapped;
}
@Override
public void write(String data) {
wrapped.write(encrypt(data));
}
@Override
public String read() {
return decrypt(wrapped.read());
}
private String encrypt(String data) { /* ... */ return data; }
private String decrypt(String data) { /* ... */ return data; }
}
// Usage — behaviors composed at runtime
DataSource source = new EncryptionDataSource(
new CompressionDataSource(
new FileDataSource("data.txt")
)
);
Strategy Pattern
public interface DiscountStrategy {
double apply(double price);
}
public class NoDiscount implements DiscountStrategy {
@Override
public double apply(double price) { return price; }
}
public class PercentageDiscount implements DiscountStrategy {
private final double percent;
public PercentageDiscount(double percent) {
this.percent = percent;
}
@Override
public double apply(double price) {
return price * (1 - percent / 100);
}
}
public class FixedDiscount implements DiscountStrategy {
private final double amount;
public FixedDiscount(double amount) {
this.amount = amount;
}
@Override
public double apply(double price) {
return Math.max(0, price - amount);
}
}
public class Product {
private final String name;
private final double price;
private DiscountStrategy discountStrategy;
public Product(String name, double price, DiscountStrategy discountStrategy) {
this.name = name;
this.price = price;
this.discountStrategy = discountStrategy;
}
public double getFinalPrice() {
return discountStrategy.apply(price);
}
public void setDiscountStrategy(DiscountStrategy strategy) {
this.discountStrategy = strategy; // Can change at runtime
}
}
Observability Checklist
- “Has-a” better describes relationship than “is-a”
- Dependencies are interfaces or abstract types, not concrete classes
- Collaborators injected via constructor (dependency injection)
- Testable — collaborators can be easily mocked
- Delegation explicit — methods forward to composed objects
Security Notes
- Dependencies on interfaces — prevents malicious subclass tampering
- Sealed classes — if inheritance is needed, control which classes can extend (Java 17+)
- Don’t expose internal collaborators — keep composed objects private
- Validate injected collaborators — null checks for required dependencies
public class SecureService {
private final Logger logger;
private final Validator validator;
// Constructor injection — dependencies clear and testable
public SecureService(Logger logger, Validator validator) {
if (logger == null) throw new IllegalArgumentException("Logger required");
if (validator == null) throw new IllegalArgumentException("Validator required");
this.logger = logger;
this.validator = validator;
}
}
Pitfalls
- Over-composition — turning everything into small interfaces when a class would suffice
- Missing delegation — forgetting to forward calls to composed objects
- Exposing composed objects — returning internal objects defeats encapsulation
- Wrapper overhead — each wrapper adds a method call (usually negligible)
- Composition without clear ownership — unclear who owns lifecycle of composed objects
// Bad: composition without delegation
public class Wrapper {
private final Inner inner;
public Wrapper(Inner inner) {
this.inner = inner;
}
// WRONG: inner never used — composition without delegation!
public void doSomething() {
// Just does its own thing, ignores inner
}
}
// Good: composition with delegation
public class Wrapper {
private final Inner inner;
public Wrapper(Inner inner) {
this.inner = inner;
}
public void doSomething() {
inner.doSomething(); // Delegates to composed object
}
}
Quick Recap
- Composition = “has-a” relationship; objects contain other objects to get behavior
- Inheritance = “is-a” relationship; subclass automatically gets parent behavior
- Favor composition when behavior might change, multiple sources of behavior needed, or coupling should be minimized
- Decorator pattern = runtime addition of behavior via composition
- Strategy pattern = runtime selection of behavior via composition
- Delegation = forward calls to composed objects, not inherit their implementation
Interview Questions
Further Reading
- Inheritance in Java — when “is-a” relationships are appropriate
- Interfaces in Java — contracts enabling composition
- Polymorphism in Java — polymorphic behavior with composed objects
- Effective Java: Item 18 — prefer composition over inheritance
- Effective Java: Item 19 — use composition for type-safe heirarchical feature enrichment
Conclusion
Composition uses “has-a” relationships where objects contain other objects to obtain behavior, favoring delegation over code inheritance. It creates loose coupling through interface dependencies, enabling runtime behavior swapping, easier testing with mocks, and avoidance of the fragile base class problem where parent changes unexpectedly break subclasses. Decorator and Strategy patterns are classic composition patterns that add or select behavior at runtime.
Prefer composition when behavior might change, multiple sources of behavior are needed, or coupling should be minimized. Use inheritance for true “is-a” relationships with stable parent classes where the coupling is acceptable and overriding behavior is needed. The golden rule: if a class needs to use functionality from another class, ask whether it “is-a” that class or “has-a” that class — composition wins in most scenarios.
This principle directly addresses the risks of inheritance by providing an alternative that avoids the tight coupling and fragile hierarchies that inheritance can create.
Category
Related Posts
Abstract Classes in Java
Learn about partially implemented classes that define contracts for subclasses using abstract methods and concrete implementations.
Arithmetic Operators in Java
Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.
Array Basics in Java
Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.