Composition vs Inheritance

2 min read

Object-Oriented Design and SOLID Review — review Java class hierarchies and decide when composition produces more flexible, testable designs than inheritance.

1. Introduction

Composition and inheritance are two common ways to reuse and organize behavior in Java.

Inheritance creates an is-a relationship.

For example:

JAVA
public class PremiumCustomer extends Customer {
}

Composition creates a has-a or uses-a relationship.

For example:

JAVA
public class OrderService {
    private final PaymentProcessor paymentProcessor;
}

Both techniques are useful, but they solve different design problems.

A common mistake in real Java projects is using inheritance only because two classes share some code.

For example, a developer may write:

JAVA
public class FileNotificationService extends EmailService {
}

only because EmailService contains useful logging or validation methods.

This creates an inheritance relationship that does not represent the business model.

The child class also becomes coupled to:

  • Parent implementation details
  • Protected state
  • Parent lifecycle
  • Future parent changes
  • Methods it may not need

Composition often provides a safer alternative because a class can reuse another component without becoming its subtype.

For example:

JAVA
public class NotificationService {
    private final MessageValidator messageValidator;
}

Now the service uses validation behavior without inheriting unrelated behavior.

In code review, the important question is not:

Can inheritance be used here?

The better question is:

Does this class genuinely represent a subtype, or are we only trying to reuse implementation?

2. What This Topic Means

Inheritance means one class derives from another.

Example:

JAVA
public class BasePaymentProcessor {
    public void validateAmount(BigDecimal amount) {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("Invalid amount");
        }
    }
}

public class CardPaymentProcessor extends BasePaymentProcessor {
    public void process(BigDecimal amount) {
        validateAmount(amount);
    }
}

CardPaymentProcessor inherits behavior from BasePaymentProcessor.

Composition means a class contains or depends on another object.

Example:

JAVA
public class PaymentValidator {
    public void validateAmount(BigDecimal amount) {
        if (amount == null || amount.signum() <= 0) {
            throw new IllegalArgumentException("Invalid amount");
        }
    }
}

public class CardPaymentProcessor {
    private final PaymentValidator paymentValidator;

    public CardPaymentProcessor(PaymentValidator paymentValidator) {
        this.paymentValidator = paymentValidator;
    }

    public void process(BigDecimal amount) {
        paymentValidator.validateAmount(amount);
    }
}

The second design does not create an artificial parent-child relationship.

CardPaymentProcessor simply uses:

JAVA
PaymentValidator

Practical Difference

Inheritance says:

This class is a specialized version of the parent and should satisfy the parent's contract.

Composition says:

This class uses another component to perform part of its job.

Inheritance is powerful when there is a genuine subtype relationship.

Composition is usually safer when the main goal is:

  • Reusing behavior
  • Delegating work
  • Swapping implementations
  • Combining capabilities
  • Avoiding parent-class coupling

3. Why It Matters in Real Projects

Maintainability

Inheritance tightly connects child classes to parent classes.

A parent change may affect many subclasses.

Composition usually localizes changes behind component boundaries.

Readability

A correct inheritance hierarchy communicates a real domain relationship.

An incorrect hierarchy creates confusion.

For example:

JAVA
CsvReportGenerator extends DatabaseService

is difficult to justify conceptually.

Composition would make the actual relationship clearer:

JAVA
CsvReportGenerator uses ReportRepository

Testability

Composed dependencies can often be replaced with:

  • Mocks
  • Fakes
  • Alternative implementations

without modifying the containing class.

Reliability

Deep inheritance hierarchies can create unexpected behavior through overrides.

Composition makes behavior more explicit because delegation is visible.

Team Development

Large parent classes shared by many subclasses are risky change points.

Composition allows teams to modify individual components independently.

Extensibility

Composition allows capabilities to be combined without creating increasingly complicated inheritance trees.

For example:

JAVA
NotificationService
    + RetryPolicy
    + MessageFormatter
    + NotificationSender

is often easier to evolve than several inheritance layers.

4. Core Concept

The practical rule commonly expressed as:

Favor composition over inheritance

does not mean:

Never use inheritance.

It means:

Do not use inheritance merely to reuse code when composition can model the dependency more accurately.

Valid Inheritance

Inheritance is appropriate when the subtype truly satisfies the parent contract.

For example:

JAVA
public interface PaymentGateway {
    PaymentResult charge(PaymentRequest request);
}

Different implementations may be:

JAVA
StripePaymentGateway
RazorpayPaymentGateway

This is interface-based polymorphism rather than implementation inheritance.

Class inheritance can also be valid when a stable hierarchy genuinely exists.

Risky Inheritance

Consider:

JAVA
public class BaseOrderService {
    protected OrderRepository orderRepository;

    protected void validate(Order order) {
    }

    protected void sendNotification(Order order) {
    }

    protected void audit(Order order) {
    }
}

public class ExpressOrderService extends BaseOrderService {
}

The subclass may inherit:

  • Validation
  • Notification
  • Auditing
  • Repository state

even if it needs only one part.

This creates strong coupling to the base class.

Composition Alternative

Instead:

JAVA
public class ExpressOrderService {
    private final OrderValidator orderValidator;
    private final OrderNotifier orderNotifier;
    private final AuditPublisher auditPublisher;
}

Each dependency now represents a specific capability.

5. Important Rules

When reviewing composition and inheritance:

  • Use inheritance only for genuine subtype relationships.
  • Do not extend a class only to reuse helper methods.
  • Prefer composition for reusable behavior.
  • Prefer delegation over overriding when behavior varies independently.
  • Avoid deep inheritance hierarchies.
  • Be cautious with large abstract base classes.
  • Keep inherited contracts stable.
  • Ensure subclasses satisfy Liskov Substitution Principle.
  • Avoid exposing parent internals through excessive protected fields.
  • Prefer private fields and constructor-injected collaborators.
  • Prefer interfaces for replaceable capabilities.
  • Do not force every implementation into one inheritance tree.
  • Use composition when capabilities can be mixed independently.
  • Avoid inheritance between unrelated service classes.
  • Avoid extending framework classes unless the framework specifically requires it.
  • Prefer strategy components when algorithms vary.
  • Keep parent classes small and stable when inheritance is justified.
  • Use final where a class or method should not be extended or overridden.
  • Avoid template-method hierarchies when simple delegation is clearer.
  • Do not replace a simple inheritance hierarchy with excessive abstraction without a real benefit.

6. Bad Code Example

Consider an e-commerce application that integrates several shipping providers.

A developer creates a large base service.

JAVA
public abstract class BaseShippingService {
    protected final ShipmentRepository shipmentRepository;
    protected final EmailSender emailSender;

    protected BaseShippingService(
        ShipmentRepository shipmentRepository,
        EmailSender emailSender
    ) {
        this.shipmentRepository = shipmentRepository;
        this.emailSender = emailSender;
    }

    protected void validateShipment(Shipment shipment) {
        if (shipment == null) {
            throw new IllegalArgumentException("Shipment must not be null");
        }

        if (shipment.getAddress() == null) {
            throw new IllegalArgumentException("Shipping address is required");
        }
    }

    protected void saveShipment(Shipment shipment) {
        shipmentRepository.save(shipment);
    }

    protected void sendConfirmation(Shipment shipment) {
        emailSender.send(
            shipment.getCustomerEmail(),
            "Shipment created: " + shipment.getId()
        );
    }

    public abstract Shipment createShipment(Order order);
}

The FedEx implementation extends it.

JAVA
@Service
public class FedExShippingService extends BaseShippingService {
    private final FedExClient fedExClient;

    public FedExShippingService(
        ShipmentRepository shipmentRepository,
        EmailSender emailSender,
        FedExClient fedExClient
    ) {
        super(shipmentRepository, emailSender);
        this.fedExClient = fedExClient;
    }

    @Override
    public Shipment createShipment(Order order) {
        FedExResponse response = fedExClient.createShipment(order);

        Shipment shipment = new Shipment();
        shipment.setProvider("FEDEX");
        shipment.setTrackingNumber(response.getTrackingNumber());
        shipment.setAddress(order.getShippingAddress());
        shipment.setCustomerEmail(order.getCustomerEmail());

        validateShipment(shipment);
        saveShipment(shipment);
        sendConfirmation(shipment);

        return shipment;
    }
}

Later, a store-pickup implementation is added.

JAVA
@Service
public class StorePickupService extends BaseShippingService {
    public StorePickupService(
        ShipmentRepository shipmentRepository,
        EmailSender emailSender
    ) {
        super(shipmentRepository, emailSender);
    }

    @Override
    public Shipment createShipment(Order order) {
        Shipment shipment = new Shipment();
        shipment.setProvider("STORE_PICKUP");
        shipment.setCustomerEmail(order.getCustomerEmail());

        saveShipment(shipment);

        return shipment;
    }
}

This looks reusable, but the hierarchy contains several problems.

7. Problems in the Bad Code

Artificial Subtype Relationship

StorePickupService is not really a shipping-provider implementation in the same operational sense as FedEx.

It does not require:

  • Shipping address
  • Carrier tracking
  • External shipment creation

The parent abstraction may therefore be too broad.

Unused Parent Behavior

StorePickupService inherits:

JAVA
validateShipment()
sendConfirmation()

even though its behavior may differ.

Protected Dependency Exposure

The base class exposes:

JAVA
shipmentRepository
emailSender

through protected fields.

Subclasses become dependent on parent internals.

Hidden Dependencies

Looking only at a subclass does not immediately show all capabilities it uses.

Some dependencies come indirectly from the parent.

Parent Change Risk

Changing BaseShippingService can affect every subclass.

For example, if saveShipment() begins publishing an event, every subclass inherits that new behavior.

Override Pressure

As implementations become different, subclasses may begin overriding more parent methods.

Eventually the hierarchy becomes difficult to reason about.

Difficult Independent Testing

Shared behavior is tied to inheritance rather than reusable focused components.

Rigid Capability Combination

Validation, persistence, and notification are bundled together through one base class.

Implementations cannot easily choose only the capabilities they need.

8. Code Review Findings

A senior reviewer should notice:

Finding 1

The base class mixes several independent reusable behaviors:

  • Validation
  • Persistence
  • Notification

Finding 2

Subclasses inherit functionality regardless of whether they need it.

Finding 3

Protected dependencies expose base-class implementation details.

Finding 4

The hierarchy is being used primarily for code reuse rather than a strong domain subtype relationship.

Finding 5

Store pickup behaves differently enough that forcing it into the same abstract hierarchy may create future exceptions and overrides.

Finding 6

Reusable capabilities could be represented through composition.

Finding 7

Adding new shipping modes may increase inheritance complexity.

Examples:

  • Courier shipping
  • Locker pickup
  • Digital delivery
  • Same-day delivery
  • Store pickup

These do not necessarily fit one implementation hierarchy cleanly.

9. Reviewer Comment Example

A practical Pull Request comment could be:

BaseShippingService appears to exist mainly to share validation, persistence, and notification behavior. Since these capabilities vary independently across delivery types, could we compose them as collaborators instead of forcing every implementation through the same base class?

Another:

StorePickupService inherits shipping-specific behavior that it does not use. That suggests the inheritance relationship may not accurately represent the domain.

Another:

The protected repository and email dependencies make subclasses dependent on base-class internals. Please consider private collaborators with explicit delegation instead.

10. Improved Code

Create focused capabilities.

JAVA
public interface DeliveryService {
    Delivery createDelivery(Order order);
}

Validation becomes a component.

JAVA
@Component
public class ShipmentValidator {
    public void validate(Shipment shipment) {
        if (shipment == null) {
            throw new IllegalArgumentException("Shipment must not be null");
        }

        if (shipment.getAddress() == null) {
            throw new IllegalArgumentException("Shipping address is required");
        }
    }
}

Persistence becomes a collaborator.

JAVA
@Service
public class ShipmentStore {
    private final ShipmentRepository shipmentRepository;

    public ShipmentStore(ShipmentRepository shipmentRepository) {
        this.shipmentRepository = shipmentRepository;
    }

    public Shipment save(Shipment shipment) {
        return shipmentRepository.save(shipment);
    }
}

Notification becomes a collaborator.

JAVA
public interface ShipmentNotifier {
    void sendCreatedNotification(Shipment shipment);
}

@Component
public class EmailShipmentNotifier implements ShipmentNotifier {
    private final EmailSender emailSender;

    public EmailShipmentNotifier(EmailSender emailSender) {
        this.emailSender = emailSender;
    }

    @Override
    public void sendCreatedNotification(Shipment shipment) {
        emailSender.send(
            shipment.getCustomerEmail(),
            "Shipment created: " + shipment.getId()
        );
    }
}

FedEx delivery composes required capabilities.

JAVA
@Service
public class FedExDeliveryService implements DeliveryService {
    private final FedExClient fedExClient;
    private final ShipmentValidator shipmentValidator;
    private final ShipmentStore shipmentStore;
    private final ShipmentNotifier shipmentNotifier;

    public FedExDeliveryService(
        FedExClient fedExClient,
        ShipmentValidator shipmentValidator,
        ShipmentStore shipmentStore,
        ShipmentNotifier shipmentNotifier
    ) {
        this.fedExClient = fedExClient;
        this.shipmentValidator = shipmentValidator;
        this.shipmentStore = shipmentStore;
        this.shipmentNotifier = shipmentNotifier;
    }

    @Override
    public Delivery createDelivery(Order order) {
        FedExResponse response = fedExClient.createShipment(order);

        Shipment shipment = Shipment.create(
            "FEDEX",
            response.getTrackingNumber(),
            order.getShippingAddress(),
            order.getCustomerEmail()
        );

        shipmentValidator.validate(shipment);

        Shipment savedShipment = shipmentStore.save(shipment);

        shipmentNotifier.sendCreatedNotification(savedShipment);

        return Delivery.shipment(savedShipment);
    }
}

Store pickup does not need shipping-specific capabilities.

JAVA
@Service
public class StorePickupDeliveryService implements DeliveryService {
    private final PickupReservationService pickupReservationService;

    public StorePickupDeliveryService(
        PickupReservationService pickupReservationService
    ) {
        this.pickupReservationService = pickupReservationService;
    }

    @Override
    public Delivery createDelivery(Order order) {
        PickupReservation reservation =
            pickupReservationService.reserve(
                order.getId(),
                order.getPreferredStoreId()
            );

        return Delivery.storePickup(reservation);
    }
}

11. Improved Code Explanation

Shared Behavior Is Composed

FedEx uses:

JAVA
ShipmentValidator
ShipmentStore
ShipmentNotifier

without inheriting from them.

Dependencies Are Explicit

The constructor clearly shows everything required by:

JAVA
FedExDeliveryService

There are no hidden protected dependencies.

Store Pickup Is Independent

StorePickupDeliveryService does not inherit irrelevant:

  • Shipment validation
  • Shipment persistence
  • Email behavior

Behavior Can Vary Independently

If notification changes, the team can replace:

JAVA
ShipmentNotifier

without altering the inheritance hierarchy.

Components Become Reusable

Another carrier can reuse:

JAVA
ShipmentValidator
ShipmentStore

without sharing a base class.

Polymorphism Is Still Available

Both implementations satisfy:

JAVA
DeliveryService

Therefore callers can still use polymorphism:

JAVA
DeliveryService deliveryService

Composition does not eliminate polymorphism.

12. Bad Code vs Improved Code

AreaInheritance-Heavy DesignComposition-Based Design
ReuseThrough base classThrough focused collaborators
DependenciesPartly hidden in parentExplicit in constructor
FlexibilityBehavior inherited as bundleCapabilities selected independently
TestabilityParent behavior tied to subclassCollaborators mocked independently
MaintainabilityParent changes affect subclassesChanges localized to components
ReadabilityRequires understanding hierarchyDependencies visible directly
ExtensibilityNew variants may require overridesNew combinations are easier
CouplingStrong parent-child couplingLower coupling between components

13. Real Project Scenario

Consider a banking application that calculates fees for different account types.

Initially, the team creates:

JAVA
BaseAccount
    -> SavingsAccount
    -> PremiumSavingsAccount
    -> SalarySavingsAccount

and separately:

JAVA
BaseAccount
    -> CurrentAccount
    -> BusinessCurrentAccount

The base class gradually accumulates:

  • Interest calculation
  • Transaction fees
  • Overdraft logic
  • Withdrawal limits
  • Reward points
  • Minimum-balance rules

Subclasses override parts of these methods.

Eventually requirements become combinations rather than clean hierarchy levels.

For example:

  • Premium account with rewards but no overdraft
  • Salary account with zero minimum balance
  • Business account with overdraft and transaction fees
  • Promotional account with temporary zero fees

Inheritance struggles because features vary independently.

Composition models the problem better.

For example:

JAVA
Account
    has InterestPolicy
    has FeePolicy
    has WithdrawalPolicy
    has OverdraftPolicy
    has RewardPolicy

A premium savings account could use:

JAVA
StandardInterestPolicy
ReducedFeePolicy
StandardWithdrawalPolicy
NoOverdraftPolicy
PremiumRewardPolicy

A business account could use:

JAVA
NoInterestPolicy
BusinessFeePolicy
BusinessWithdrawalPolicy
CreditOverdraftPolicy
NoRewardPolicy

The system can combine capabilities without creating dozens of subclasses.

14. Production Impact

Poor inheritance design can cause significant production and maintenance risks.

Unexpected Behavior After Parent Changes

A change in the base class may silently alter every subclass.

Regression Across Unrelated Variants

Fixing one child through shared parent logic may break another.

Runtime Unsupported Behavior

Subclasses may eventually override methods only to throw:

JAVA
UnsupportedOperationException

Complex Debugging

Developers must inspect several inheritance levels to understand which method actually executes.

Fragile Overrides

A parent may call overridable methods during its lifecycle, producing behavior that is difficult to predict.

Difficult Provider Changes

If provider-specific logic is embedded in an inheritance tree, replacing one provider may require structural changes.

High Maintenance Cost

Large hierarchies become difficult to modify safely because developers need to understand all subclasses before changing base behavior.

15. Common Developer Mistakes

Mistake 1: Using extends for Code Reuse

Example:

JAVA
ReportService extends FileUtils

only to reuse file helper methods.

Use composition or focused utility behavior instead.

Mistake 2: Creating BaseService

Common examples include:

JAVA
BaseService
AbstractService
CommonService

These classes often become dumping grounds for unrelated reusable methods.

Mistake 3: Deep Inheritance Hierarchy

Example:

JAVA
BaseProcessor
    -> PaymentProcessor
        -> CardProcessor
            -> InternationalCardProcessor
                -> PremiumInternationalCardProcessor

Each level increases cognitive complexity.

Mistake 4: Excessive protected Fields

Protected mutable fields allow subclasses to manipulate parent internals.

Mistake 5: Overriding Most Parent Methods

If a subclass overrides most inherited behavior, the parent relationship may provide little value.

Mistake 6: Unsupported Parent Methods

Example:

JAVA
@Override
public void refund() {
    throw new UnsupportedOperationException();
}

This may indicate both LSP and inheritance-design problems.

Mistake 7: Composition for Everything

Composition is not automatically superior in every situation.

Simple, stable inheritance can be appropriate.

Mistake 8: Confusing Interface Implementation With Class Inheritance

Implementing:

JAVA
PaymentGateway

is often a useful form of polymorphism without inheriting implementation.

Mistake 9: Extending Framework Classes Unnecessarily

Do not extend framework implementations just to customize one small behavior when extension points or composition are available.

Mistake 10: Creating Wrapper Classes With No Purpose

Composition should provide a meaningful boundary, not unnecessary delegation layers.

16. Edge Cases

Template Method Pattern

A stable algorithm with clearly defined extension points can legitimately use inheritance.

For example:

JAVA
abstract class BatchJob {
    final void execute() {
        validate();
        process();
        publishResult();
    }

    protected abstract void process();
}

This can be reasonable when the workflow is stable and variation is intentionally limited.

However, reviewers should ensure subclasses do not need to override most of the workflow.

Framework Requirements

Some frameworks intentionally require inheritance.

Examples may include framework base classes or generated APIs.

Use required inheritance, but avoid adding additional application coupling unnecessarily.

Immutable Base Classes

Inheritance from immutable, stable classes can be safer than inheritance from stateful mutable bases.

Interfaces

Composition and interface-based polymorphism commonly work together.

For example:

JAVA
PricingService

can compose:

JAVA
DiscountPolicy

while different discount policies implement one interface.

JPA Inheritance

JPA supports entity inheritance strategies.

These should be chosen based on actual domain and database requirements rather than general preference for inheritance.

Records

Java records are final and cannot be extended.

They work well for immutable data carriers but are not intended for class inheritance hierarchies.

Sealed Classes

Sealed classes can make controlled inheritance safer when the permitted subtype set is intentional and known.

For example:

JAVA
public sealed interface PaymentResult
    permits PaymentSuccess, PaymentFailure {
}

This is a case where a restricted type hierarchy communicates the domain clearly.

17. Performance Considerations

Composition versus inheritance is primarily a design decision.

Normal Java delegation and virtual method invocation overhead is usually insignificant for typical enterprise applications.

Object Creation

Composition may introduce additional collaborator objects.

With Spring singleton beans, these are usually created once and reused.

The memory impact is normally negligible.

Method Delegation

Calling:

JAVA
validator.validate(...)

instead of an inherited:

JAVA
validate(...)

adds no meaningful performance concern in most backend systems.

Database and Network Operations

Performance is dominated by:

  • SQL queries
  • HTTP calls
  • Serialization
  • Messaging
  • File I/O

not normal composition delegation.

Excessive Chains

An unnecessarily deep composition graph can make architecture difficult to navigate, but this is usually a maintainability issue rather than runtime performance.

Do not choose inheritance solely because it appears to require fewer method calls.

Profile actual performance problems instead.

18. Security Considerations

Composition and inheritance are not security mechanisms themselves.

However, poor inheritance can accidentally widen access.

Protected Sensitive State

Avoid parent classes containing:

JAVA
protected String apiKey;
protected String accessToken;

Every subclass gains direct access.

Prefer private state inside a focused integration component.

Inherited Privileged Methods

A subclass may inherit administrative behavior that it should not expose.

Overridable Security Checks

Be careful when security-sensitive logic can be overridden.

For example:

JAVA
protected boolean isAuthorized(User user)

may allow a subclass to weaken authorization unintentionally.

Final Security Operations

If a security-critical workflow uses inheritance, consider whether certain methods should be:

JAVA
final

to prevent unsafe overrides.

Composition Boundary

A class can receive only the security capability it actually needs.

For example:

JAVA
AuthorizationChecker

instead of inheriting from a large security base class.

Authorization must still be enforced explicitly regardless of the reuse mechanism.

19. Testing Considerations

Composition generally makes unit tests more focused.

Unit Testing a Composed Service

For:

JAVA
FedExDeliveryService

mock:

JAVA
FedExClient
ShipmentValidator
ShipmentStore
ShipmentNotifier

Each responsibility can also be tested independently.

Positive Case

Verify:

  • Provider creates shipment.
  • Validation runs.
  • Shipment is saved.
  • Notification is sent.

Provider Failure

Verify that persistence and notification do not occur when the carrier call fails.

Validation Failure

Verify invalid shipment data is rejected.

Notification Failure

Define expected behavior:

  • Should delivery creation fail?
  • Should notification be retried separately?

Inheritance Tests

When inheritance is used, test parent-contract behavior across subclasses.

For example:

  • Does every subtype preserve base invariants?
  • Are overridden methods behaviorally compatible?
  • Does base-class refactoring break subclasses?

Regression Testing

Base-class changes deserve broad regression coverage because one change can affect many subclasses.

20. Refactoring Guidelines

Refactoring inheritance into composition should be incremental.

Step 1: Identify Why Inheritance Exists

Ask whether the hierarchy represents:

  • Genuine subtype relationship
  • Code reuse
  • Shared state
  • Framework requirement
  • Template workflow

Do not refactor blindly.

Step 2: List Inherited Capabilities

For example:

JAVA
validate()
save()
notify()
audit()

Step 3: Identify Independent Responsibilities

Determine which behaviors vary separately.

Step 4: Extract Collaborators

Create components such as:

JAVA
Validator
RepositoryAdapter
Notifier
AuditPublisher

Step 5: Inject Collaborators

Replace parent-method calls with explicit delegation.

Step 6: Move Subclasses to an Interface

Where polymorphism is needed, introduce:

JAVA
DeliveryService

or another meaningful contract.

Step 7: Migrate One Implementation at a Time

Avoid converting an entire large hierarchy in one PR when production behavior is complex.

Step 8: Preserve Tests

Add characterization tests before changing inherited behavior.

Step 9: Remove Base Class

After all necessary behavior has moved, remove the obsolete base class.

Step 10: Review Remaining Inheritance

Some inheritance may still be valid.

Keep it if it clearly models the domain and remains stable.

21. Best Practices

  • Prefer composition for implementation reuse.
  • Use inheritance for genuine subtype relationships.
  • Keep inheritance hierarchies shallow.
  • Keep base-class contracts stable.
  • Prefer interfaces for polymorphic capabilities.
  • Use constructor injection for composed collaborators.
  • Keep collaborator responsibilities focused.
  • Avoid protected mutable state.
  • Prefer private fields in base classes.
  • Use delegation when behavior varies independently.
  • Use strategy objects for interchangeable algorithms.
  • Keep shared infrastructure out of abstract business base classes.
  • Use final to prevent extension where inheritance is not intended.
  • Consider sealed types for controlled domain hierarchies.
  • Keep subclasses behaviorally compatible with parents.
  • Refactor inheritance gradually.
  • Keep composition simple and meaningful.
  • Prefer business-oriented names for components.
  • Test contracts, not only implementations.

22. Practices to Avoid

Extending for Utility Reuse

Avoid:

JAVA
class InvoiceService extends StringUtils

Large Abstract Base Services

Avoid base classes containing repositories, HTTP clients, logging, validation, notification, and persistence behavior.

Deep Hierarchies

Avoid several inheritance levels unless the domain clearly requires them.

Protected Everything

Avoid making fields and helper methods protected merely so subclasses can reuse them.

Unsupported Overrides

Avoid methods implemented only with:

JAVA
throw new UnsupportedOperationException();

Override-Based Configuration

Avoid requiring subclasses to override many small methods merely to configure behavior.

Composition or configuration objects may be clearer.

Inheritance Across Unrelated Domains

Avoid:

JAVA
CustomerService extends OrderService

simply because both require common helpers.

Framework Implementation Extension Without Need

Prefer documented extension points.

Excessive Composition

Avoid turning a simple three-line behavior into several unnecessary wrappers.

Interface Explosion

Do not create an interface for every implementation unless a meaningful contract or boundary exists.

23. Code Review Checklist

Ask these questions during Pull Request review:

  • Is this a genuine is-a relationship?
  • Is inheritance being used mainly to reuse code?
  • Could the shared behavior be represented as a collaborator?
  • Does the subclass use most inherited behavior?
  • Does the subclass override many parent methods?
  • Does any subclass throw UnsupportedOperationException for inherited methods?
  • Are protected fields exposing parent internals?
  • Would a parent change unexpectedly affect many subclasses?
  • Is the hierarchy deeper than necessary?
  • Can the child safely satisfy the parent contract?
  • Is Liskov Substitution Principle preserved?
  • Are independent capabilities bundled into one base class?
  • Could strategy objects represent the varying behavior more clearly?
  • Would constructor injection make dependencies more explicit?
  • Is polymorphism required, or is only code reuse required?
  • Could an interface provide polymorphism without implementation inheritance?
  • Does the composition design introduce unnecessary wrappers?
  • Is framework inheritance actually required?
  • Would a sealed hierarchy communicate the domain better?
  • Are base-class methods safe to override?
  • Should any critical method be final?
  • Does testing require understanding several inheritance layers?
  • Would the proposed refactoring reduce coupling?
  • Is the final design simpler than the current hierarchy?

24. Common Pull Request Review Comments

  1. This subclass appears to extend BaseService only to reuse validation logic. Could we extract the validation into a collaborator instead of creating an inheritance relationship?
  1. The child overrides most of the inherited behavior, which suggests the base abstraction may not be providing a useful contract. Please consider composition here.
  1. The protected repository makes every subclass dependent on base-class internals. Can we inject the repository or a focused collaborator directly where it is needed?
  1. StorePickupService does not use several shipping-specific parent methods. That makes the subtype relationship questionable.
  1. Changing this base method will affect six subclasses. Could this varying behavior be moved into a strategy component to reduce the blast radius?
  1. This override throws UnsupportedOperationException, which suggests the subclass cannot honor the parent contract. Please review whether inheritance is appropriate.
  1. We still need polymorphism here, but implementation inheritance is not required. Could these services implement a common interface and compose their dependencies independently?
  1. This hierarchy is now four levels deep. Please check whether the middle base classes are modeling actual domain types or only sharing implementation.
  1. The composition version introduces three wrapper classes around trivial behavior. That may be more abstraction than this use case needs; a simpler design may be preferable.
  1. This looks like a valid stable subtype relationship, so inheritance may be clearer than introducing another delegation layer.

25. Code Review Exercise

Review the following Spring Boot code.

JAVA
public abstract class BaseNotificationService {
    protected final NotificationRepository notificationRepository;
    protected final AuditPublisher auditPublisher;

    protected BaseNotificationService(
        NotificationRepository notificationRepository,
        AuditPublisher auditPublisher
    ) {
        this.notificationRepository = notificationRepository;
        this.auditPublisher = auditPublisher;
    }

    public void send(Notification notification) {
        validate(notification);
        doSend(notification);
        notification.setStatus(NotificationStatus.SENT);
        notificationRepository.save(notification);
        auditPublisher.publish(
            "NOTIFICATION_SENT",
            notification.getId()
        );
    }

    protected void validate(Notification notification) {
        if (notification == null) {
            throw new IllegalArgumentException("Notification must not be null");
        }

        if (notification.getRecipient() == null) {
            throw new IllegalArgumentException("Recipient is required");
        }
    }

    protected abstract void doSend(Notification notification);
}

@Service
public class EmailNotificationService extends BaseNotificationService {
    private final EmailClient emailClient;

    public EmailNotificationService(
        NotificationRepository notificationRepository,
        AuditPublisher auditPublisher,
        EmailClient emailClient
    ) {
        super(notificationRepository, auditPublisher);
        this.emailClient = emailClient;
    }

    @Override
    protected void doSend(Notification notification) {
        emailClient.send(
            notification.getRecipient(),
            notification.getMessage()
        );
    }
}

@Service
public class InAppNotificationService extends BaseNotificationService {
    private final InAppNotificationRepository inAppNotificationRepository;

    public InAppNotificationService(
        NotificationRepository notificationRepository,
        AuditPublisher auditPublisher,
        InAppNotificationRepository inAppNotificationRepository
    ) {
        super(notificationRepository, auditPublisher);
        this.inAppNotificationRepository = inAppNotificationRepository;
    }

    @Override
    protected void doSend(Notification notification) {
        inAppNotificationRepository.save(
            InAppMessage.from(notification)
        );
    }
}

@Service
public class SilentAuditNotificationService extends BaseNotificationService {
    public SilentAuditNotificationService(
        NotificationRepository notificationRepository,
        AuditPublisher auditPublisher
    ) {
        super(notificationRepository, auditPublisher);
    }

    @Override
    protected void doSend(Notification notification) {
    }
}

Identify:

  • Inheritance risks
  • Hidden dependencies
  • Questionable subtype relationships
  • Protected-state concerns
  • Template-method benefits
  • Unsupported or misleading behavior
  • Areas where composition may help
  • Areas where inheritance may still be reasonable

Do not reveal the answer until you complete your own review.

26. Exercise Solution

The example contains both reasonable inheritance ideas and important risks.

Issue 1: Base Class Owns Too Many Concerns

BaseNotificationService handles:

  • Validation
  • Delivery workflow
  • Persistence
  • Auditing
  • Status management

The subclass inherits the whole bundle.

Issue 2: Hidden Dependencies

Every subclass depends indirectly on:

JAVA
NotificationRepository
AuditPublisher

These dependencies are not local to the subclass behavior.

Issue 3: SilentAuditNotificationService Is Suspicious

Its implementation:

JAVA
protected void doSend(Notification notification) {
}

does nothing.

However, the base class still marks the notification:

JAVA
SENT

and publishes:

JAVA
NOTIFICATION_SENT

This is misleading and likely incorrect.

Issue 4: Inheritance May Still Have Some Value

The base class represents a template workflow:

  1. Validate.
  2. Send.
  3. Mark sent.
  4. Persist.
  5. Audit.

If every notification channel genuinely follows exactly this lifecycle, a template method could be valid.

The problem is that the third subtype demonstrates that not every implementation necessarily fits the workflow.

Composition Alternative

Define a delivery capability.

JAVA
public interface NotificationSender {
    void send(Notification notification);
}

Validation component:

JAVA
@Component
public class NotificationValidator {
    public void validate(Notification notification) {
        if (notification == null) {
            throw new IllegalArgumentException("Notification must not be null");
        }

        if (notification.getRecipient() == null) {
            throw new IllegalArgumentException("Recipient is required");
        }
    }
}

Email sender:

JAVA
@Component
public class EmailNotificationSender implements NotificationSender {
    private final EmailClient emailClient;

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

    @Override
    public void send(Notification notification) {
        emailClient.send(
            notification.getRecipient(),
            notification.getMessage()
        );
    }
}

In-app sender:

JAVA
@Component
public class InAppNotificationSender implements NotificationSender {
    private final InAppNotificationRepository inAppNotificationRepository;

    public InAppNotificationSender(
        InAppNotificationRepository inAppNotificationRepository
    ) {
        this.inAppNotificationRepository = inAppNotificationRepository;
    }

    @Override
    public void send(Notification notification) {
        inAppNotificationRepository.save(
            InAppMessage.from(notification)
        );
    }
}

Central orchestration:

JAVA
@Service
public class NotificationDeliveryService {
    private final NotificationValidator notificationValidator;
    private final NotificationRepository notificationRepository;
    private final AuditPublisher auditPublisher;

    public NotificationDeliveryService(
        NotificationValidator notificationValidator,
        NotificationRepository notificationRepository,
        AuditPublisher auditPublisher
    ) {
        this.notificationValidator = notificationValidator;
        this.notificationRepository = notificationRepository;
        this.auditPublisher = auditPublisher;
    }

    @Transactional
    public void deliver(
        Notification notification,
        NotificationSender notificationSender
    ) {
        notificationValidator.validate(notification);

        notificationSender.send(notification);

        notification.markSent();

        notificationRepository.save(notification);

        auditPublisher.publish(
            "NOTIFICATION_SENT",
            notification.getId()
        );
    }
}

Why Composition Helps

The sender implementations contain only channel-specific behavior.

They do not inherit:

  • Persistence
  • Audit logic
  • Validation internals

NotificationDeliveryService owns the common workflow explicitly.

The silent audit implementation should be modeled as a different business operation rather than pretending to send a notification.

When Inheritance Could Still Be Reasonable

If:

  • Every implementation always follows the same workflow.
  • Only one clearly defined step varies.
  • The base contract is stable.
  • No implementation needs to skip or reorder steps.
  • The subclass relationship is intentional.

then a template-method base class could remain reasonable.

The key is not blindly replacing inheritance.

The reviewer should choose the design that models the real variation most clearly.

27. Interview Perspective

Composition versus inheritance is a common Java and senior-developer interview topic.

A basic interviewer may ask:

What is the difference between inheritance and composition?

An experienced-level discussion usually goes further.

For example:

You have four Spring services extending BaseService only to reuse validation and logging. Would you keep this design?

A strong answer should explain:

  • The inheritance relationship may exist only for implementation reuse.
  • Parent changes can affect all subclasses.
  • Dependencies may become hidden.
  • Validation and logging can often be composed or handled through dedicated infrastructure mechanisms.
  • A shared interface can provide polymorphism without implementation inheritance.

Another common question is:

Why do developers say "favor composition over inheritance"?

A strong answer should clarify that composition:

  • Reduces parent-child coupling.
  • Allows independent behavior replacement.
  • Makes dependencies explicit.
  • Avoids fragile inheritance hierarchies.

But also mention:

Inheritance is still valid when there is a genuine and stable subtype relationship.

Senior interviews may discuss:

  • Liskov Substitution Principle
  • Template Method pattern
  • Strategy pattern
  • Decorator pattern
  • Sealed classes
  • JPA inheritance
  • Framework extension points
  • Deep hierarchy risks

28. Interview Questions and Answers

Basic Question

Question: What is the difference between composition and inheritance?

Answer:

Inheritance models an is-a relationship.

A subclass inherits behavior and state from a parent class.

Composition models a has-a or uses-a relationship.

A class delegates work to another component.

For implementation reuse, composition is often safer because it creates less coupling to parent internals.

Intermediate Question

Question: Why is composition often preferred over inheritance?

Answer:

Composition keeps capabilities independent.

A class can use only the behavior it needs and replace collaborators independently.

Inheritance couples subclasses to:

  • Parent contract
  • Parent implementation
  • Protected behavior
  • Lifecycle decisions
  • Future parent changes

Composition generally provides more flexibility when behavior varies independently.

Advanced Question

Question: When would you still choose inheritance?

Answer:

I would use inheritance when:

  • There is a genuine subtype relationship.
  • The parent contract is stable.
  • Subclasses can honor the complete parent behavior.
  • Shared workflow is intentional.
  • The hierarchy is shallow and understandable.
  • The child does not need to disable inherited behavior.

A controlled template-method design or sealed domain hierarchy can be valid uses of inheritance.

Scenario-Based Question

Question: Five payment services extend BasePaymentService only because it contains logging, validation, and repository methods. What would you recommend?

Answer:

I would first determine whether the subclasses represent a genuine subtype hierarchy.

If inheritance exists mainly for reusable helper behavior, I would likely extract:

JAVA
PaymentValidator
PaymentRepository or persistence component
Audit/Logging component

and compose them into the services.

The payment implementations could still implement:

JAVA
PaymentGateway

for polymorphism.

This preserves replaceability without forcing implementation inheritance.

Code-Review Question

Question: What signs suggest inheritance is being misused?

Answer:

I would look for:

  • Subclasses overriding most parent methods.
  • Unsupported inherited methods.
  • Empty overrides.
  • Large abstract base services.
  • Protected dependencies.
  • Deep hierarchies.
  • Children that are conceptually unrelated to the parent.
  • Base classes used only for utility reuse.
  • Frequent parent changes causing subclass regressions.

These are strong signals to consider composition.

Real-Project Question

Question: How would composition help a Spring Boot pricing system?

Answer:

Instead of creating subclasses for every combination of discount, tax, loyalty, and fee behavior, a pricing service can compose policies:

JAVA
DiscountPolicy
TaxPolicy
FeePolicy
LoyaltyPolicy

Different product or customer configurations can select different implementations.

This avoids combinatorial subclass growth and makes each pricing rule independently testable and replaceable.

29. Quick Rule to Remember

Use inheritance when the child truly is the parent; use composition when the class simply needs another component's behavior.

30. Final Takeaway

Composition and inheritance are both useful Java design techniques, but they should not be used interchangeably.

Developers should remember:

  • Inheritance models a genuine subtype relationship.
  • Composition models collaboration between independent capabilities.
  • Do not use extends merely to reuse helper code.
  • Deep inheritance hierarchies increase maintenance risk.
  • Protected state creates strong subclass coupling.
  • Composition makes dependencies explicit.
  • Interfaces provide polymorphism without forcing implementation inheritance.
  • Strategy-style components work well when behavior varies independently.
  • Template-method inheritance can still be valid for stable workflows.
  • Liskov Substitution Principle should hold whenever inheritance is used.
  • Composition should not be introduced mechanically when simple inheritance already models the domain correctly.

During Pull Request review, reviewers should check:

  • Why inheritance is being introduced.
  • Whether the subtype relationship is genuine.
  • Whether the subclass uses and honors the parent contract.
  • Whether shared behavior could be extracted into collaborators.
  • Whether parent dependencies are hidden from subclasses.
  • Whether overrides are becoming excessive.
  • Whether unsupported inherited methods exist.
  • Whether a parent change could affect many unrelated implementations.
  • Whether composition would simplify testing and future changes.
  • Whether the proposed alternative remains simple rather than over-engineered.

Production code should avoid hierarchies where developers repeatedly think:

JAVA
"This class is not really a subtype, but extending the base class saves some code."

Shared code alone is not enough reason to create inheritance.

A strong Java design uses inheritance when the domain relationship is real and stable, and composition when behavior should remain flexible, independently replaceable, and loosely coupled.