Composition over Inheritance in Java

Learn when Java composition beats inheritance, how delegation supports design patterns, and how to prevent brittle code, security gaps, and production failures.

published: reading time: 21 min read author: Geek Workbench
Quick Summary

Composition gives a class behavior through collaborators it can replace, while inheritance fixes behavior into a parent-child hierarchy. This guide compares the trade-offs with examples from stacks, maps, logging, payment retries, and authorization. It also shows how delegation, Decorator, and Strategy keep implementation choices flexible, and what to check when wrappers, ownership, or production wiring can change system behavior.

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 Car has an Engine, not is an Engine
  • 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 — Dog is an Animal, inheritance is appropriate
  • You need to override parent behavior — inheritance lets you override methods
  • Shared immutable state — inheritance with final fields 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 — its bucket array, collision handling, and hash distribution — isn’t guaranteed by the Map interface. In Java 8 and later, a collision-heavy bucket can be treeified into a red-black tree after certain thresholds are reached; most buckets remain lists. Code that relies on bucket structure or iteration order is still fragile across implementations and versions. There’s no way to swap HashMap for LinkedHashMap or TreeMap in a subclass without rewriting the class. Composition avoids that dependency by relying on the Map interface, so an 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

  • Narrow collaborator contracts — keep each dependency limited to the operations it needs; still validate and authorize its behavior
  • 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

  1. Over-composition — turning everything into small interfaces when a class would suffice
  2. Missing delegation — forgetting to forward calls to composed objects
  3. Exposing composed objects — returning internal objects defeats encapsulation
  4. Wrapper overhead — each wrapper adds a method call (usually negligible)
  5. 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
    }
}

Production Failure Scenarios

Composition keeps a subclass from inheriting APIs it should not expose, but composed behavior can still fail when wrappers are wired in the wrong order. A common example is retrying a payment operation after a timeout. The gateway may have charged the card even though the response was lost; retrying can charge it twice.

public final class RetryingPayment implements PaymentGateway {
    private final PaymentGateway delegate;

    public RetryingPayment(PaymentGateway delegate) {
        this.delegate = delegate;
    }

    @Override
    public Receipt charge(Payment payment) {
        try {
            return delegate.charge(payment);
        } catch (TransientGatewayException timeout) {
            // A timeout does not prove that the first charge failed.
            return delegate.charge(payment);
        }
    }
}

In production, customers report duplicate charges while service logs show a timeout followed by a successful retry. Make the operation idempotent with a stable payment request key, and have the gateway return the original result for repeated keys. Keep retries in a decorator only when the operation’s contract makes retrying safe; alert on repeated requests and reconcile uncertain outcomes instead of assuming every timeout means failure.

Inheritance can produce a quieter version of the same problem: a subclass may preserve its parent’s method signatures while a parent change alters the order or side effects of those methods. Symptoms include overrides being skipped, duplicate hooks, or a subclass invariant breaking after a library upgrade. Keep inheritance shallow and based on documented extension points. If callers need only one capability, depend on a small interface and compose an implementation whose behavior is explicit.

Security and Compliance Notes

An interface describes methods, not trust. A collaborator supplied through dependency injection can still be misconfigured, compromised, or implemented in a way that violates the contract. Put authorization at the use-case boundary, validate values before delegation, and avoid treating a wrapper as a substitute for access control. For regulated data, keep audit and retention requirements visible in the service boundary and verify that decorators cannot be omitted from a production wiring path.

public final class AuthorizedPaymentService {
    private final PaymentGateway gateway;
    private final PaymentPolicy policy;

    public AuthorizedPaymentService(PaymentGateway gateway, PaymentPolicy policy) {
        this.gateway = Objects.requireNonNull(gateway);
        this.policy = Objects.requireNonNull(policy);
    }

    public Receipt charge(User user, Payment payment) {
        Objects.requireNonNull(user);
        Objects.requireNonNull(payment);
        policy.requireCanCharge(user, payment);
        return gateway.charge(payment);
    }
}

Do not log payment details or raw personal data from a decorator just because it sits around the gateway. Log a request identifier and the outcome needed for investigation, with sensitive values redacted. Review decorator order as part of security review: encryption must happen before data crosses an untrusted storage boundary, and logging or metrics wrappers must not capture secrets. Tests should exercise the assembled production chain as well as the individual collaborators.

Common Pitfalls and Anti-Patterns

Delegation is easy to make incomplete. A wrapper that forwards get to one object and put to another can appear correct in isolated tests, then return stale values under real use. Keep one clear delegate for each contract and forward every supported operation consistently. If the wrapper intentionally changes semantics, document that contract rather than presenting it as a drop-in replacement.

public final class CachedCatalog implements Catalog {
    private final Catalog delegate;
    private final Map<String, Item> cache = new HashMap<>();

    public CachedCatalog(Catalog delegate) {
        this.delegate = Objects.requireNonNull(delegate);
    }

    @Override
    public Item find(String id) {
        return cache.computeIfAbsent(id, delegate::find);
    }

    @Override
    public void save(Item item) {
        delegate.save(item);
        cache.put(item.id(), item); // Keep reads consistent after writes.
    }
}

The cache example also needs an explicit concurrency and invalidation policy if multiple threads can update the catalog. Other warning signs are wrappers that expose their internal collaborators, chains whose order is undocumented, and strategies that keep mutable state shared across requests. Prefer constructor injection for required collaborators, immutable strategy objects where possible, and focused tests for both the wrapper and its wiring. Composition should make ownership and behavior clearer; if every call requires tracing through several opaque layers, simplify the chain.

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

Quick Recap Checklist

  • Check whether the relationship is really “has-a” or a valid “is-a” subtype.
  • Depend on the smallest interface that provides the needed behavior.
  • Pass changeable collaborators into the class, usually through its constructor.
  • Delegate deliberately, and keep internal collaborators private.
  • Use Decorator to wrap behavior and Strategy to swap an algorithm.
  • Keep inheritance when the subtype honors a stable parent contract.

Interview Questions

1. Why should you prefer composition over inheritance?
Composition creates looser coupling — classes depend on interfaces, not concrete parent implementations. With composition, you can swap implementations at runtime, test with mocks easily, and avoid the fragile base class problem where parent implementation changes break subclasses. Inheritance works best for true "is-a" relationships with stable parent classes.
2. How does composition enable runtime flexibility?
Because composed objects are typically accessed through interfaces, you can swap implementations at runtime without changing the containing class. For example, you can inject a `MockLogger` in tests and a `FileLogger` in production, both satisfying the same `Logger` interface.
3. What is the decorator pattern?
The decorator pattern wraps an object in another object that adds behavior, without modifying the original class. Each decorator implements the same interface as the wrapped object and delegates calls to it after doing something extra. Decorators can be nested to add multiple behaviors at runtime.
4. When is inheritance actually the right choice?
Inheritance is appropriate when there is a true "is-a" relationship (a `Dog` truly is an `Animal`), when you need to override behavior, when the parent class is stable (won't change unexpectedly), and when the coupling that inheritance creates is acceptable. Simple, shallow hierarchies with immutable parent classes are the safest use case.
5. What is the delegation pattern and how does it relate to composition?
Delegation is passing method calls to composed objects rather than implementing behavior directly. In composition with delegation, a class contains an interface-typed collaborator and its methods call the collaborator's methods. The Decorator and Strategy patterns both rely on delegation to add or swap behavior.
6. What is the difference between inheritance coupling and composition coupling?
Inheritance creates tight coupling — a subclass depends on parent implementation details. Composition creates loose coupling — a class depends on an interface contract, not an implementation. When a parent class changes, subclasses can break; when an interface changes, you can manage it with adapters.
7. What is the relationship between composition and the Strategy pattern?
The Strategy pattern uses composition to select behavior at runtime. A context class holds an interface reference, and the client can set different strategy implementations. All strategies implement the same interface, so the context doesn't know which strategy it uses — behavior is determined at runtime.
8. What is the difference between composition and aggregation?
In composition, the contained object's lifetime matches the container's — the container owns the parts. In aggregation, the contained object can exist independently — it has a separate lifetime. Both use "has-a" relationships; the difference is ownership and lifecycle semantics.
9. How does composition support the Interface Segregation Principle?
ISP states that classes should not be forced to depend on methods they don't use. Composition allows depending on small, focused interfaces rather than large ones. A class requiring only `Readable` can compose with that rather than depending on an entire `FileHandler` interface.
10. When using composition, how do you decide what interfaces to create?
Create interfaces around the behavior your class needs from collaborators. Follow Single Responsibility: each interface represents one role or capability. Name interfaces by what they allow (Readable, Writable, Serializable) rather than by what implements them.
11. How does composition relate to dependency injection?
Dependency injection passes dependencies (composed objects) from outside rather than creating them internally. Constructor injection declares dependencies as interface-typed constructor parameters. This makes code more testable and flexible — dependencies can be swapped at construction time.
12. What is the difference between composition and inheritance for code reuse?
Inheritance: code is "inherited" into a subclass — runs in subclass context with direct access to parent state. Composition: code is "delegated" to a collaborator — the collaborator runs its own code and the class wraps the result. Composition reuse is via delegation; inheritance reuse is via code copying into subclasses.
13. Can composition and inheritance be used together?
Yes — a class can extend one class (inheritance) and compose with interfaces (composition). A common pattern is to extend a `BaseClass` and implement several interface-typed collaborators. This gives shared implementation from the parent plus flexible behavior from composition.
14. What is the "composition over inheritance" principle's impact on class hierarchies?
It reduces deep inheritance hierarchies by replacing "is-a" chains with "has-a" collaborations. This results in flatter hierarchies with more interfaces and fewer parent-child dependencies. The outcome is more composable, flexible code that can adapt to changing requirements.
15. What is the difference between forwarding and delegation in composition?
Forwarding is when a wrapper delegates to a collaborator without adding any behavior of its own. Delegation is when a wrapper may add behavior before or after calling the collaborator's method. The Decorator pattern is delegation; a simple wrapper interface is forwarding.
16. How does composition help avoid the "diamond problem" in Java?
Java doesn't support multiple class inheritance, so the diamond problem is avoided for state. Composition avoids it by using interfaces without state — there is no ambiguity in method resolution. Interface default methods can still cause diamond issues, but only for behavior, not state inheritance.
17. When does composition make a class easier to change?
When the class needs behavior that may vary, composition lets it depend on a collaborator's interface and receive a different implementation. For example, a service can use a real logger in production and a test logger in a unit test without changing the service itself.
18. Does "favor composition over inheritance" mean inheritance is always wrong?
No. Inheritance fits a genuine subtype relationship when the parent contract is stable and the subtype can honor it. A `Dog` can be an `Animal`; a `Stack` should not extend `ArrayList` if callers can use list operations that break stack behavior.
19. How do Decorator and Strategy use composition differently?
A Decorator wraps an object behind the same interface and adds work around its calls, such as encrypting data before writing it. A Strategy gives a class a replaceable algorithm, such as a discount rule. Both rely on delegation, but solve different variation problems.
20. What should you check when a class composes a collaborator?
Check that the dependency expresses only the behavior the class needs, that calls are delegated where intended, and that ownership is clear. Keep the collaborator private unless callers truly need access to it; otherwise the wrapper's boundary is easy to bypass.
## Further Reading

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.

Summary

Composition lets Java classes gain behavior through collaborators and delegation, which makes those behaviors easier to replace and test. Reach for it when a class has something or needs a choice of behavior; use inheritance when the subtype relationship is real and its contract holds.

Category

Related Posts

Abstract Classes in Java

Learn about partially implemented classes that define contracts for subclasses using abstract methods and concrete implementations.

#java-abstract-classes #java #java-fundamentals

Arithmetic Operators in Java

Master Java arithmetic operators: addition, subtraction, multiplication, division, and modulo with integer division gotchas and operator precedence explained.

#java-arithmetic-operators #java #java-fundamentals

Array Basics in Java

Learn Java array fundamentals: declaration, initialization, element access, and the length property explained simply.

#java-array-basics #java #java-fundamentals