1. Introduction
Composition and inheritance are two common ways to reuse and organize behavior in Java.
Inheritance creates an is-a relationship.
For example:
public class PremiumCustomer extends Customer {
}Composition creates a has-a or uses-a relationship.
For example:
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:
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:
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:
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:
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:
PaymentValidatorPractical 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:
CsvReportGenerator extends DatabaseServiceis difficult to justify conceptually.
Composition would make the actual relationship clearer:
CsvReportGenerator uses ReportRepositoryTestability
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:
NotificationService
+ RetryPolicy
+ MessageFormatter
+ NotificationSenderis 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:
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}Different implementations may be:
StripePaymentGateway
RazorpayPaymentGatewayThis is interface-based polymorphism rather than implementation inheritance.
Class inheritance can also be valid when a stable hierarchy genuinely exists.
Risky Inheritance
Consider:
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:
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
protectedfields. - Prefer
privatefields 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
finalwhere 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.
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.
@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.
@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:
validateShipment()
sendConfirmation()even though its behavior may differ.
Protected Dependency Exposure
The base class exposes:
shipmentRepository
emailSenderthrough 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:
BaseShippingServiceappears 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:
StorePickupServiceinherits 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.
public interface DeliveryService {
Delivery createDelivery(Order order);
}Validation becomes a component.
@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.
@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.
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.
@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.
@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:
ShipmentValidator
ShipmentStore
ShipmentNotifierwithout inheriting from them.
Dependencies Are Explicit
The constructor clearly shows everything required by:
FedExDeliveryServiceThere 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:
ShipmentNotifierwithout altering the inheritance hierarchy.
Components Become Reusable
Another carrier can reuse:
ShipmentValidator
ShipmentStorewithout sharing a base class.
Polymorphism Is Still Available
Both implementations satisfy:
DeliveryServiceTherefore callers can still use polymorphism:
DeliveryService deliveryServiceComposition does not eliminate polymorphism.
12. Bad Code vs Improved Code
| Area | Inheritance-Heavy Design | Composition-Based Design |
|---|---|---|
| Reuse | Through base class | Through focused collaborators |
| Dependencies | Partly hidden in parent | Explicit in constructor |
| Flexibility | Behavior inherited as bundle | Capabilities selected independently |
| Testability | Parent behavior tied to subclass | Collaborators mocked independently |
| Maintainability | Parent changes affect subclasses | Changes localized to components |
| Readability | Requires understanding hierarchy | Dependencies visible directly |
| Extensibility | New variants may require overrides | New combinations are easier |
| Coupling | Strong parent-child coupling | Lower coupling between components |
13. Real Project Scenario
Consider a banking application that calculates fees for different account types.
Initially, the team creates:
BaseAccount
-> SavingsAccount
-> PremiumSavingsAccount
-> SalarySavingsAccountand separately:
BaseAccount
-> CurrentAccount
-> BusinessCurrentAccountThe 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:
Account
has InterestPolicy
has FeePolicy
has WithdrawalPolicy
has OverdraftPolicy
has RewardPolicyA premium savings account could use:
StandardInterestPolicy
ReducedFeePolicy
StandardWithdrawalPolicy
NoOverdraftPolicy
PremiumRewardPolicyA business account could use:
NoInterestPolicy
BusinessFeePolicy
BusinessWithdrawalPolicy
CreditOverdraftPolicy
NoRewardPolicyThe 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:
UnsupportedOperationExceptionComplex 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:
ReportService extends FileUtilsonly to reuse file helper methods.
Use composition or focused utility behavior instead.
Mistake 2: Creating BaseService
Common examples include:
BaseService
AbstractService
CommonServiceThese classes often become dumping grounds for unrelated reusable methods.
Mistake 3: Deep Inheritance Hierarchy
Example:
BaseProcessor
-> PaymentProcessor
-> CardProcessor
-> InternationalCardProcessor
-> PremiumInternationalCardProcessorEach 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:
@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:
PaymentGatewayis 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:
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:
PricingServicecan compose:
DiscountPolicywhile 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:
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:
validator.validate(...)instead of an inherited:
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:
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:
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:
finalto prevent unsafe overrides.
Composition Boundary
A class can receive only the security capability it actually needs.
For example:
AuthorizationCheckerinstead 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:
FedExDeliveryServicemock:
FedExClient
ShipmentValidator
ShipmentStore
ShipmentNotifierEach 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:
validate()
save()
notify()
audit()Step 3: Identify Independent Responsibilities
Determine which behaviors vary separately.
Step 4: Extract Collaborators
Create components such as:
Validator
RepositoryAdapter
Notifier
AuditPublisherStep 5: Inject Collaborators
Replace parent-method calls with explicit delegation.
Step 6: Move Subclasses to an Interface
Where polymorphism is needed, introduce:
DeliveryServiceor 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
finalto 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:
class InvoiceService extends StringUtilsLarge 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:
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:
CustomerService extends OrderServicesimply 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
UnsupportedOperationExceptionfor 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
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?
The child overrides most of the inherited behavior, which suggests the base abstraction may not be providing a useful contract. Please consider composition here.
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?
StorePickupService does not use several shipping-specific parent methods. That makes the subtype relationship questionable.
Changing this base method will affect six subclasses. Could this varying behavior be moved into a strategy component to reduce the blast radius?
This override throws UnsupportedOperationException, which suggests the subclass cannot honor the parent contract. Please review whether inheritance is appropriate.
We still need polymorphism here, but implementation inheritance is not required. Could these services implement a common interface and compose their dependencies independently?
This hierarchy is now four levels deep. Please check whether the middle base classes are modeling actual domain types or only sharing implementation.
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.
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.
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:
NotificationRepository
AuditPublisherThese dependencies are not local to the subclass behavior.
Issue 3: SilentAuditNotificationService Is Suspicious
Its implementation:
protected void doSend(Notification notification) {
}does nothing.
However, the base class still marks the notification:
SENTand publishes:
NOTIFICATION_SENTThis is misleading and likely incorrect.
Issue 4: Inheritance May Still Have Some Value
The base class represents a template workflow:
- Validate.
- Send.
- Mark sent.
- Persist.
- 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.
public interface NotificationSender {
void send(Notification notification);
}Validation component:
@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:
@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:
@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:
@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:
PaymentValidator
PaymentRepository or persistence component
Audit/Logging componentand compose them into the services.
The payment implementations could still implement:
PaymentGatewayfor 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:
DiscountPolicy
TaxPolicy
FeePolicy
LoyaltyPolicyDifferent 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
extendsmerely 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:
"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.