Skip to main content
Java intermediate Lesson 22 of 58

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

SituationChoose
”is-a” with stable, narrow hierarchyInheritance
Code reuse across unrelated classesComposition
Need to swap behaviour at runtimeComposition
Adding capabilities to a class you don’t ownComposition (Decorator)
Multiple behaviours from unrelated concernsInterface mixins
Sharing fields and state across subclassesAbstract 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.

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.