Software Dev

Composition Over Inheritance in Java: When to Use Each and How to Refactor Legacy Code 2026

One of the most misused features in object-oriented Java codebases is inheritance. While the language makes it trivially easy to write class Child extends Parent, over-relying on inheritance produces fragile, tightly-coupled systems that collapse under change. This guide gives you a battle-tested framework for deciding when to use inheritance, when to use composition, and how to safely refactor legacy hierarchies to composed designs using modern Java & Spring Boot.

Md Sanwar Hossain April 9, 2026 22 min read Design Principles
Composition over inheritance in Java — software design principle and refactoring guide 2026

TL;DR — The Rule in One Sentence

"Use inheritance only when a true IS-A relationship exists and the Liskov Substitution Principle is satisfied without exceptions. In every other case — and especially when behavior varies by configuration, not type — compose using interfaces and injected collaborators. Composition produces systems that are easier to test, extend, and refactor."

Table of Contents

  1. Understanding Inheritance in Java
  2. The Problems with Inheritance Overuse
  3. What Is Composition?
  4. Composition Over Inheritance: Core Principle
  5. When Inheritance IS Appropriate
  6. Refactoring Inheritance to Composition: Step-by-Step
  7. Composition Patterns in Spring Boot
  8. Common Composition Mistakes
  9. Comparison Table & Decision Framework

1. Understanding Inheritance in Java

Inheritance is one of the four pillars of object-oriented programming. In Java, you establish an inheritance relationship with the extends keyword, which creates an IS-A relationship: a subclass is a specialization of its superclass. The subclass inherits all non-private fields and methods from the parent, can call the parent's constructor via super(), and can override methods to provide specialized behavior.

How Java Inheritance Works

At its core, Java uses single-class inheritance (a class can extend exactly one superclass) but multiple interface implementation. The classic textbook example is a zoological hierarchy:

// IS-A hierarchy: Animal → Mammal → Dog
public abstract class Animal {
    protected String name;
    protected int age;

    public Animal(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public abstract String makeSound();

    public String describe() {
        return name + " (age " + age + ") says: " + makeSound();
    }
}

public abstract class Mammal extends Animal {
    protected boolean hasFur;

    public Mammal(String name, int age, boolean hasFur) {
        super(name, age);          // delegate to parent constructor
        this.hasFur = hasFur;
    }

    public boolean isWarmBlooded() { return true; }
}

public class Dog extends Mammal {
    private String breed;

    public Dog(String name, int age, String breed) {
        super(name, age, true);    // all dogs have fur
        this.breed = breed;
    }

    @Override
    public String makeSound() { return "Woof!"; }

    public String fetch() { return name + " fetches the ball!"; }
}

What Inheritance Is Designed For: IS-A Relationships

Inheritance shines when modeling genuine taxonomic relationships where a subtype is truly a more-specific version of the supertype — at all times and without exception. The litmus test: replace the subclass with the superclass in any context and the program must still be semantically correct. This is the Liskov Substitution Principle (LSP), and it is the dividing line between correct and abusive inheritance.

The Fragile Base Class Problem

When a superclass changes its internal implementation — even without changing its public interface — subclasses can silently break. This is called the fragile base class problem. The parent class exposes its internals (via protected fields and methods) to every subclass, creating hidden coupling that ordinary encapsulation cannot prevent. A seemingly safe refactoring in Animal can cascade into failures across every concrete animal subclass, across every module.

Why Deep Hierarchies Become Unmanageable

Every level added to an inheritance hierarchy multiplies complexity. To understand the behavior of a leaf class three levels deep, a developer must read and mentally simulate the constructor chains, method resolution order, and override interactions across all three ancestor classes. This cognitive overhead compounds. In real-world codebases, hierarchies of depth 5+ become virtually impossible to reason about without running the code.

Composition over inheritance in Java — design principle comparison diagram
Inheritance hierarchy vs. composition-based design in Java — structural comparison. Source: mdsanwarhossain.me

2. The Problems with Inheritance Overuse

Most inheritance abuse in production Java codebases doesn't come from modeling zoological taxonomies — it comes from engineers reusing code via inheritance when they should be composing. Let's examine each pathology.

Tight Coupling to Parent Implementation

A subclass doesn't just inherit the interface of its parent — it inherits its implementation details. This means the subclass depends on how the parent chose to implement a method today, not just what that method promises. When the parent's implementation changes (even internally), the subclass may behave incorrectly without any compile-time warning. The subclass is tightly coupled to a specific version of the parent's internals.

Breaking Encapsulation: Parent Internals Exposed to Children

Java's protected modifier exists specifically to give subclasses access to parent internals. But this access is a two-edged sword: it encourages subclasses to reach into parent state directly, creating invisible dependencies. When the parent restructures its protected fields or method contracts, every subclass breaks. The encapsulation boundary of the parent class is permanently weakened by the existence of subclasses.

The Yo-Yo Problem

When understanding a method call requires jumping up and down a deep hierarchy — from the concrete class to the abstract parent, back to the concrete class's override, up to the grandparent for a super call, down again — developers experience the Yo-Yo problem. IDE navigation becomes the primary way to trace logic. Code reviews are exhausting. Onboarding new engineers to deep hierarchies takes weeks.

Refused Bequest: Inheriting Methods You Don't Need

The Refused Bequest code smell occurs when a subclass inherits methods from a parent but doesn't use them — or worse, overrides them to throw UnsupportedOperationException. A classic Java SDK example: java.util.Stack extends Vector. Stack inherits all of Vector's random-access methods (get(int index), add(int index, E element)), which violate the stack data structure's invariant. This is an IS-A relationship Java's standard library authors regret.

The Employee Hierarchy Gone Wrong

A real-world example of inheritance abuse common in enterprise Java applications:

// ANTI-PATTERN: inheritance used for code reuse, not IS-A
public class Employee {
    protected String name;
    protected double baseSalary;
    protected String department;

    public double calculatePay() {
        return baseSalary;
    }

    public void sendPerformanceReview(String review) {
        // email logic
    }

    public void approvePurchase(double amount) {
        throw new UnsupportedOperationException("Regular employees cannot approve purchases");
        // REFUSED BEQUEST — subclasses inherit this to override with an exception
    }
}

public class Manager extends Employee {
    private double bonus;
    private List<Employee> directReports;

    @Override
    public double calculatePay() {
        return super.baseSalary + bonus;  // tightly coupled to parent field
    }

    @Override
    public void approvePurchase(double amount) {
        if (amount > 50_000) throw new IllegalStateException("Exceeds manager limit");
        // actual approval logic
    }
}

public class SeniorManager extends Manager {
    private double stockOptions;

    @Override
    public double calculatePay() {
        return super.calculatePay() + stockOptions;  // yo-yo: must read Manager AND Employee
    }
    // SeniorManager "inherits" sendPerformanceReview — but SeniorManagers
    // don't review themselves. Already the IS-A is strained.
}

This hierarchy has multiple problems: calculatePay() is scattered across three classes with shared mutable state; adding a Contractor who has no department forces either an awkward subclass of Employee or a complete redesign; introducing a PartTimeManager triggers a combinatorial explosion of subclasses.

The Diamond Problem in Java Interfaces

Java avoids the classical diamond inheritance problem for classes (only single inheritance allowed), but Java 8+ default methods in interfaces reintroduce it. If a class implements two interfaces that both provide a default implementation of the same method, the compiler forces an explicit override — a design-time conflict that signals ambiguous responsibilities. Composition cleanly sidesteps this entire class of problems.

3. What Is Composition?

Composition is the design technique of building complex objects by combining simpler, focused objects. Instead of a class inheriting behavior from a parent, it holds references to collaborator objects that provide that behavior. The relationship is HAS-A rather than IS-A: an Employee HAS-A SalaryCalculator, not IS-A SalaryCalculator.

HAS-A vs IS-A

The key insight is that composition expresses capabilities through owned objects, while inheritance expresses type identity. When you find yourself inheriting to reuse code rather than to express a type relationship, you should be composing instead.

Interface-Based Composition in Java

Effective composition in Java relies on programming to interfaces, not implementations. The composing class depends on an interface contract; the concrete implementation can be swapped without touching the composing class:

// Extracted interfaces — focused, single-responsibility
public interface SalaryCalculator {
    double calculate(double baseSalary);
}

public interface PerformanceReviewer {
    void sendReview(String employeeId, String review);
}

public interface PurchaseApprover {
    void approve(String purchaseId, double amount);
}

// Concrete implementations are small, testable, swappable
public class FullTimeSalaryCalculator implements SalaryCalculator {
    private final double bonusRate;

    public FullTimeSalaryCalculator(double bonusRate) {
        this.bonusRate = bonusRate;
    }

    @Override
    public double calculate(double baseSalary) {
        return baseSalary * (1 + bonusRate);
    }
}

public class ContractorSalaryCalculator implements SalaryCalculator {
    private final double hourlyRate;
    private final int hoursWorked;

    public ContractorSalaryCalculator(double hourlyRate, int hoursWorked) {
        this.hourlyRate = hourlyRate;
        this.hoursWorked = hoursWorked;
    }

    @Override
    public double calculate(double baseSalary) {
        return hourlyRate * hoursWorked; // ignores baseSalary entirely
    }
}

// Composed Employee — HAS-A collaborators
public class Employee {
    private final String id;
    private final String name;
    private final double baseSalary;
    private final SalaryCalculator salaryCalculator;
    private final PerformanceReviewer performanceReviewer;

    public Employee(String id, String name, double baseSalary,
                    SalaryCalculator salaryCalculator,
                    PerformanceReviewer performanceReviewer) {
        this.id = id;
        this.name = name;
        this.baseSalary = baseSalary;
        this.salaryCalculator = salaryCalculator;
        this.performanceReviewer = performanceReviewer;
    }

    public double calculatePay() {
        return salaryCalculator.calculate(baseSalary);  // delegate
    }

    public void sendReview(String review) {
        performanceReviewer.sendReview(id, review);     // delegate
    }
}

Dependency Injection Enables Composition in Spring Boot

Spring Boot's dependency injection container is the perfect enabler for composition-based design. Instead of constructing collaborators manually, you declare them as dependencies and Spring wires them at startup. This means your Employee service never needs to know which SalaryCalculator implementation it gets — Spring decides based on configuration or qualifier annotations. Composition + DI = maximum flexibility with minimal coupling.

4. Composition Over Inheritance: Core Principle

The principle "favor composition over inheritance" was codified by the Gang of Four (GoF) in their seminal 1994 book Design Patterns: Elements of Reusable Object-Oriented Software. Item 18 of Joshua Bloch's Effective Java states it bluntly: "Favor composition over inheritance." It is one of the most consistently validated principles in software engineering across three decades of OOP practice.

The Principle: Compose Behavior From Smaller Units

Instead of building behavior by specializing a superclass, you assemble behavior from small, focused objects that each do one thing well. This mirrors the Unix philosophy applied to OOP: small, composable units over large, monolithic hierarchies. Each collaborator is independently testable, replaceable, and reusable across entirely different class hierarchies.

When Composition Is Superior: Behavior Varies by Configuration

Composition is unambiguously superior whenever behavior needs to vary by runtime configuration rather than by type. If you find yourself creating subclasses to represent combinations of features (e.g., EmailNotificationService, SmsNotificationService, EmailAndSmsNotificationService), that is a sign you need composition — not more subclasses.

Decorator Pattern as Composition

The Decorator pattern wraps an object with another that adds behavior, stacking capabilities without inheritance. Spring's own AOP proxy mechanism is implemented as decoration. Here's a simple Java example:

public interface DataProcessor {
    String process(String input);
}

public class CoreDataProcessor implements DataProcessor {
    @Override
    public String process(String input) {
        return input.trim().toLowerCase();
    }
}

// Decorator — adds logging, wraps the real implementation
public class LoggingDataProcessor implements DataProcessor {
    private final DataProcessor delegate;

    public LoggingDataProcessor(DataProcessor delegate) {
        this.delegate = delegate;
    }

    @Override
    public String process(String input) {
        System.out.println("Processing: " + input);
        String result = delegate.process(input);
        System.out.println("Result: " + result);
        return result;
    }
}

// Stack decorators at construction time — no inheritance needed
DataProcessor processor =
    new LoggingDataProcessor(
        new CoreDataProcessor()
    );

Strategy Pattern as Composition

The Strategy pattern replaces conditional branching and subclass specialization with a pluggable algorithm interface. The context holds a reference to a strategy interface; the algorithm is selected and injected at construction or runtime:

// Strategy interface — the varying algorithm
public interface SortStrategy<T extends Comparable<T>> {
    List<T> sort(List<T> items);
}

// Concrete strategies — swappable without touching DataService
public class QuickSortStrategy<T extends Comparable<T>> implements SortStrategy<T> {
    @Override
    public List<T> sort(List<T> items) {
        List<T> copy = new ArrayList<>(items);
        Collections.sort(copy);  // simplified
        return copy;
    }
}

public class MergeSortStrategy<T extends Comparable<T>> implements SortStrategy<T> {
    @Override
    public List<T> sort(List<T> items) {
        // merge sort implementation
        return items;
    }
}

// Context uses composition — HAS-A strategy
public class DataService<T extends Comparable<T>> {
    private final SortStrategy<T> sortStrategy;

    public DataService(SortStrategy<T> sortStrategy) {
        this.sortStrategy = sortStrategy;
    }

    public List<T> getSortedData(List<T> rawData) {
        return sortStrategy.sort(rawData);
    }
}

5. When Inheritance IS Appropriate

Composition over inheritance is not "never use inheritance." There are legitimate, well-scoped scenarios where inheritance is the right tool. Knowing both sides of the trade-off is what separates senior engineers from those who blindly follow rules.

True IS-A Relationships (Liskov Substitution Principle)

If every instance of the subclass is, without exception, a valid instance of the superclass — and can be passed wherever the superclass is expected without altering program correctness — then inheritance is justified. Square extends Shape is fine if Shape contracts are defined appropriately. IllegalArgumentException extends RuntimeException is correct; you always want to catch the parent in catch blocks.

Template Method Pattern — Inheritance by Design

The Template Method pattern defines a skeleton algorithm in an abstract base class, with hook methods that subclasses fill in. This is one of the GoF patterns that deliberately uses inheritance. The base class controls the algorithm's flow; the subclasses provide the varying steps:

// Template Method — intentional, controlled inheritance
public abstract class ReportGenerator {

    // Template method — the algorithm skeleton (final to prevent override)
    public final Report generate(ReportRequest request) {
        ReportData data = fetchData(request);           // hook
        ReportData processed = processData(data);       // hook
        return formatReport(processed);                 // hook
    }

    protected abstract ReportData fetchData(ReportRequest request);
    protected abstract ReportData processData(ReportData raw);
    protected abstract Report formatReport(ReportData data);
}

public class PdfReportGenerator extends ReportGenerator {
    @Override
    protected ReportData fetchData(ReportRequest req) { /* PDF data fetch */ return null; }

    @Override
    protected ReportData processData(ReportData raw) { /* PDF processing */ return raw; }

    @Override
    protected Report formatReport(ReportData data) { /* render PDF */ return new Report(); }
}

Framework Extension Points (Spring AbstractController)

Frameworks intentionally publish abstract base classes as extension points. Spring's AbstractController, JUnit's AbstractJUnit4SpringContextTests, and many batch processing base classes are designed for inheritance. The framework controls the lifecycle; you extend to plug in your logic. These are well-documented, intentional IS-A relationships.

IS-A Relationship Checklist

Question Yes → No →
Is the subclass always substitutable for the superclass (LSP)? Inheritance candidate Use composition
Will every inherited method be semantically valid on the subclass? Inheritance candidate Use composition
Is the relationship intrinsic to type identity (not configurable)? Inheritance candidate Use composition
Is the superclass designed and documented for extension? Inheritance candidate Use composition
Is the hierarchy depth ≤ 2 (excluding framework base classes)? Acceptable ⚠️ Reconsider

6. Refactoring Inheritance to Composition: Step-by-Step

When you've identified an inheritance hierarchy that should be composition, follow this systematic refactoring process. We'll work through a concrete example: a NotificationService hierarchy that became unmanageable as notification channels multiplied.

Step 1: Identify What Methods Are Overridden vs Inherited

Before — the fragile inheritance hierarchy:

// BEFORE — inheritance hierarchy that explodes combinatorially
public abstract class NotificationService {
    protected String subject;
    protected String body;

    public void setContent(String subject, String body) {
        this.subject = subject;
        this.body = body;
    }

    public abstract void send(String recipient);

    public void logNotification(String recipient) {
        System.out.println("Sent to: " + recipient + " | Subject: " + subject);
    }
}

public class EmailNotificationService extends NotificationService {
    private EmailClient emailClient;

    @Override
    public void send(String recipient) {
        emailClient.sendEmail(recipient, subject, body);
        logNotification(recipient);
    }
}

public class SmsNotificationService extends NotificationService {
    private SmsClient smsClient;

    @Override
    public void send(String recipient) {
        smsClient.sendSms(recipient, body);  // SMS has no subject
        logNotification(recipient);
    }
}

// What do you do when you need Email + SMS? Create this nightmare class?
public class EmailAndSmsNotificationService extends NotificationService {
    // duplicates EmailNotificationService AND SmsNotificationService...
    // This is inheritance's combinatorial explosion
}

Step 2: Extract the Varying Behavior Into an Interface

Identify what actually varies: the delivery channel. Extract it:

// Step 2: Extract the varying behavior — the delivery channel
public interface NotificationChannel {
    void deliver(String recipient, String subject, String body);
}

// Step 3: Create concrete channel implementations
public class EmailChannel implements NotificationChannel {
    private final EmailClient emailClient;

    public EmailChannel(EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    @Override
    public void deliver(String recipient, String subject, String body) {
        emailClient.sendEmail(recipient, subject, body);
    }
}

public class SmsChannel implements NotificationChannel {
    private final SmsClient smsClient;

    public SmsChannel(SmsClient smsClient) {
        this.smsClient = smsClient;
    }

    @Override
    public void deliver(String recipient, String subject, String body) {
        smsClient.sendSms(recipient, body);
    }
}

public class PushNotificationChannel implements NotificationChannel {
    private final PushClient pushClient;

    public PushNotificationChannel(PushClient pushClient) {
        this.pushClient = pushClient;
    }

    @Override
    public void deliver(String recipient, String subject, String body) {
        pushClient.push(recipient, subject, body);
    }
}

Step 4 & 5: Compose and Inject

After — a single NotificationService that composes any combination of channels without subclassing:

// AFTER — composed design: no inheritance, infinite channel combinations
public class NotificationService {
    private final List<NotificationChannel> channels;
    private final NotificationLogger logger;

    // Constructor injection — channels are externally configured
    public NotificationService(List<NotificationChannel> channels,
                                NotificationLogger logger) {
        this.channels = channels;
        this.logger = logger;
    }

    public void send(String recipient, String subject, String body) {
        for (NotificationChannel channel : channels) {
            channel.deliver(recipient, subject, body);
        }
        logger.log(recipient, subject);
    }
}

// Caller decides the channel combination at construction — no subclassing
NotificationService emailOnly = new NotificationService(
    List.of(new EmailChannel(emailClient)),
    logger
);

NotificationService allChannels = new NotificationService(
    List.of(new EmailChannel(emailClient),
            new SmsChannel(smsClient),
            new PushNotificationChannel(pushClient)),
    logger
);

// In Spring Boot: channels are injected by the container
@Service
public class OrderNotificationService {
    private final NotificationService notificationService;

    public OrderNotificationService(
            @Qualifier("orderChannels") List<NotificationChannel> channels,
            NotificationLogger logger) {
        this.notificationService = new NotificationService(channels, logger);
    }
}

The refactored design eliminates the class hierarchy entirely. Adding a SlackChannel means creating one new class that implements NotificationChannel. No existing code changes. The Open/Closed Principle is naturally satisfied.

7. Composition Patterns in Spring Boot

Spring Boot's entire architecture is built on composition. Understanding the composition patterns that Spring enables helps you write idiomatic, production-quality Spring code.

@Component Composition: PaymentProcessor

A production payment processor is composed of multiple focused collaborators, each individually testable and replaceable:

// Each collaborator is a focused @Component / @Service
@Component
public class FraudChecker {
    public FraudCheckResult check(PaymentRequest request) {
        // ML-based fraud scoring
        return FraudCheckResult.PASS;
    }
}

@Component
public class CurrencyConverter {
    public BigDecimal convertToUsd(BigDecimal amount, String fromCurrency) {
        // call exchange rate service
        return amount;
    }
}

@Component
public class PaymentGateway {
    public PaymentResult charge(String cardToken, BigDecimal amountUsd) {
        // call Stripe / Braintree
        return PaymentResult.SUCCESS;
    }
}

// Composed PaymentProcessor — no inheritance anywhere
@Service
public class PaymentProcessor {
    private final FraudChecker fraudChecker;
    private final CurrencyConverter currencyConverter;
    private final PaymentGateway paymentGateway;
    private final PaymentEventPublisher eventPublisher;

    // Spring constructor injection
    public PaymentProcessor(FraudChecker fraudChecker,
                             CurrencyConverter currencyConverter,
                             PaymentGateway paymentGateway,
                             PaymentEventPublisher eventPublisher) {
        this.fraudChecker = fraudChecker;
        this.currencyConverter = currencyConverter;
        this.paymentGateway = paymentGateway;
        this.eventPublisher = eventPublisher;
    }

    public PaymentResult process(PaymentRequest request) {
        FraudCheckResult fraud = fraudChecker.check(request);
        if (fraud == FraudCheckResult.FAIL) {
            return PaymentResult.FRAUD_REJECTED;
        }
        BigDecimal amountUsd = currencyConverter.convertToUsd(
            request.getAmount(), request.getCurrency()
        );
        PaymentResult result = paymentGateway.charge(request.getCardToken(), amountUsd);
        eventPublisher.publish(new PaymentProcessedEvent(request.getId(), result));
        return result;
    }
}

Decorator with Spring Proxies (AOP)

Spring AOP implements decorator-pattern behavior via dynamic proxies. @Transactional, @Cacheable, @Retryable, and @Async all wrap your bean with decorator proxies that add cross-cutting behavior without you writing decorator classes manually. This is Spring's composition model in action — you never need to extend a transaction base class.

Chain of Responsibility via Composed Handlers

Spring Security's filter chain is a textbook Chain of Responsibility pattern built with composition. You can replicate this in your own code for request processing pipelines:

// Chain of Responsibility via composed handlers
public interface OrderHandler {
    OrderResult handle(OrderRequest request, OrderHandlerChain chain);
}

@Component
public class ValidationHandler implements OrderHandler {
    @Override
    public OrderResult handle(OrderRequest request, OrderHandlerChain chain) {
        if (!request.isValid()) return OrderResult.INVALID;
        return chain.proceed(request);  // delegate to next handler
    }
}

@Component
public class InventoryHandler implements OrderHandler {
    private final InventoryService inventoryService;

    public InventoryHandler(InventoryService inventoryService) {
        this.inventoryService = inventoryService;
    }

    @Override
    public OrderResult handle(OrderRequest request, OrderHandlerChain chain) {
        if (!inventoryService.isAvailable(request.getProductId())) {
            return OrderResult.OUT_OF_STOCK;
        }
        return chain.proceed(request);
    }
}

// Pipeline composed in configuration — order matters, no inheritance
@Configuration
public class OrderPipelineConfig {
    @Bean
    public OrderHandlerChain orderChain(ValidationHandler validation,
                                         InventoryHandler inventory,
                                         PaymentHandler payment,
                                         FulfillmentHandler fulfillment) {
        return new OrderHandlerChain(List.of(validation, inventory, payment, fulfillment));
    }
}

Builder Pattern for Complex Object Construction

The Builder pattern is composition in time: you assemble an immutable object incrementally through a fluent interface, composing its properties step by step. Lombok's @Builder generates this boilerplate. For domain objects with 10+ optional fields, Builder + composition prevents telescoping constructors and avoids the need for "configuration subclasses."

8. Common Composition Mistakes

Like any principle, composition can be misapplied. Here are the pitfalls to avoid when moving from inheritance-heavy to composition-based design.

Over-Delegation: Too Many Delegations, No Clear Owner

When a class composes 15 collaborators and every method is a one-liner delegation, you have replaced the complexity of a deep hierarchy with the complexity of a sprawling flat graph. The class becomes a mere coordinator with no behavior of its own. Apply the Single Responsibility Principle to the composition itself: a class should have a cohesive set of collaborators that address one primary concern. If you can't explain what a composed class "does" in one sentence, split it.

When Not to Compose: The YAGNI Warning

Don't extract a SalaryCalculator interface if there is currently only one salary calculation algorithm and there are no near-term requirements for a second. Over-abstraction from premature composition creates indirection with no payoff. Apply YAGNI (You Aren't Gonna Need It): only extract an interface when a second implementation is genuinely needed or when testing requires mocking the collaborator.

Performance Considerations

Interface dispatch in the JVM (virtual method calls through an interface reference) has historically been slower than direct method calls. In practice, the JIT compiler (especially with Java 21's improved JIT and Project Leyden ahead-of-time compilation) inlines and optimizes most interface dispatch to zero overhead for hotspot code paths. For performance-critical tight loops, benchmark before assuming composition is a bottleneck — in 99% of cases it is not.

Testing: Composition Makes Mocking Easier

One of composition's biggest practical advantages is testability. When your class holds interface-typed collaborators, you can inject Mockito mocks or fakes in tests without any framework magic:

@ExtendWith(MockitoExtension.class)
class PaymentProcessorTest {

    @Mock
    private FraudChecker fraudChecker;

    @Mock
    private CurrencyConverter currencyConverter;

    @Mock
    private PaymentGateway paymentGateway;

    @Mock
    private PaymentEventPublisher eventPublisher;

    private PaymentProcessor processor;

    @BeforeEach
    void setUp() {
        // Pure constructor injection — no Spring context needed
        processor = new PaymentProcessor(
            fraudChecker, currencyConverter, paymentGateway, eventPublisher
        );
    }

    @Test
    void should_reject_fraudulent_payment() {
        PaymentRequest request = PaymentRequest.builder()
            .cardToken("tok_test")
            .amount(BigDecimal.valueOf(100))
            .currency("USD")
            .build();

        when(fraudChecker.check(request)).thenReturn(FraudCheckResult.FAIL);

        PaymentResult result = processor.process(request);

        assertThat(result).isEqualTo(PaymentResult.FRAUD_REJECTED);
        verifyNoInteractions(currencyConverter, paymentGateway);  // short-circuit verified
    }
}

Contrast this with testing a deep inheritance hierarchy where you must instantiate the entire parent chain, often triggering side effects that require additional mocking. Composition makes unit tests fast, isolated, and readable.

9. Comparison Table & Decision Framework

Use this reference table when deciding between inheritance and composition on a new design or when evaluating an existing hierarchy during code review.

Criterion Inheritance Composition
Relationship type IS-A (type identity) HAS-A (collaborator)
Coupling High (to parent implementation) Low (to interface contract)
Encapsulation Weakened (protected internals) Strong (black-box collaborators)
Runtime flexibility None (fixed at compile time) High (swap at runtime/config)
Testability Hard (requires full parent) Easy (mock interfaces)
Combinatorial features Class explosion Compose at construction time
Code reuse Implicit (all parent methods) Explicit (only delegated methods)
Fragile base class risk High None
Polymorphism Natural (subtype polymorphism) Via interface (parametric)
Effective Java guidance Item 17: design for extension or prohibit it Item 18: favor composition over inheritance

Decision Flowchart

Is it an IS-A relationship?

├── NO → Use composition (HAS-A, delegating collaborators)

└── YES → Does it satisfy LSP without exception?

├── NO → Use composition

└── YES → Is the superclass designed for subclassing?

├── NO (third-party, no docs) → Use composition with wrapper

└── YES → Is the hierarchy depth ≤ 2?

├── NO → Flatten to composition

└── YES → ✅ Inheritance is appropriate here

Industry Guidance: Effective Java Item 18

Joshua Bloch's Effective Java Item 18 provides the clearest professional guidance: "Inheritance is appropriate only in circumstances where the subclass really is a subtype of the superclass. In other words, a class B should extend a class A only if an 'is-a' relationship exists between the two classes. If you are tempted to have a class B extend a class A, ask yourself the question: Is every B really an A? If you cannot truthfully answer yes to this question, B should not extend A."

Bloch further warns about inheriting from classes not designed for extension. When you extend a concrete class from a third-party library, you bind your implementation to the library's internal behavior — a coupling you cannot safely break when the library upgrades. Composition via a wrapper (the Forwarding class pattern from Item 18) is the safe alternative.

Code Review Checklist: Inheritance vs Composition

  • ☐ Does the subclass pass the "Is every B an A?" test without exception?
  • ☐ Are all inherited methods semantically valid on the subclass?
  • ☐ Is the hierarchy depth ≤ 2 (excluding framework base classes)?
  • ☐ Could the varying behavior be extracted to an interface instead?
  • ☐ Does the subclass override methods to throw UnsupportedOperationException? (red flag)
  • ☐ Are there more than 3 subclasses with combinatorial feature overlap? (composition needed)
  • ☐ Would adding a new "type" require creating a new class vs. changing configuration? (if new class → consider composition)
  • ☐ Is the superclass a third-party class not designed for extension? (use wrapper)

Every mature Java codebase converges on a simple discipline: inheritance is reserved for deliberate IS-A taxonomies and framework extension points; composition handles everything else. The codebases that apply this consistently are the ones that remain changeable — and therefore valuable — years after they were written. Start auditing your inheritance hierarchies today: you will find composition opportunities in every non-trivial Java project.

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices · AI/LLM Systems

All Posts
Last updated: April 9, 2026