Java inheritance creates an IS-A relationship via the extends keyword
Child classes inherit all non-private members from a single parent
Method overriding with @Override enables polymorphic behaviour
Dynamic dispatch selects the correct method at runtime based on actual object type
Production risk: fragile base class problems when changing parent breaks children
Always apply the IS-A test before choosing inheritance over composition
✦ Definition~90s read
What is Inheritance in Java?
Java inheritance is a core mechanism where a class (child/subclass) derives fields and methods from another class (parent/superclass) using the extends keyword. It exists to enable code reuse and establish an "is-a" relationship — a Dog is an Animal, so Dog inherits Animal's behavior without rewriting it.
★
Think of a smartphone.
Java enforces single inheritance for classes (a class can extend only one parent) to avoid the diamond problem, but supports multilevel (A→B→C) and hierarchical (one parent, many children). You reach for inheritance when you have a clear taxonomic hierarchy and shared behavior; you avoid it when composition (has-a) or interfaces (contracts) better model the relationship, as deep inheritance chains become brittle — changing a parent method can silently break dozens of subclasses, which is exactly the scenario this article addresses.
In practice, inheritance is a compile-time coupling: the child class gets a direct dependency on the parent's implementation. Method overriding lets a child replace inherited behavior with its own version, but the super keyword gives explicit access to the parent's version, and constructor chaining ensures the parent's initialization runs first (via an implicit super() call).
Java 16+ sealed classes (sealed, permits) restrict which classes can extend a parent, giving you control over the inheritance hierarchy and enabling exhaustive pattern matching with instanceof — a safer alternative to open-ended inheritance. The tradeoff is real: inheritance is powerful for frameworks (e.g., HttpServlet → MyServlet) but dangerous in large codebases where a single parent change cascades through 30+ subclasses, as the article's title warns.
Alternatives include interfaces (for pure contracts), composition (delegating behavior to contained objects), and abstract classes (for partial implementation). When you see extends in a codebase, ask: is this a genuine "is-a" relationship, or would composition reduce coupling?
The Java standard library itself uses inheritance sparingly in newer APIs — List.of() returns an unmodifiable list via composition, not inheritance. Understanding these tradeoffs is what separates senior engineers from those who wake up to 30 broken classes after a parent method change.
Plain-English First
Think of a smartphone. Every smartphone — whether it's a Samsung, an iPhone, or a Pixel — has things in common: a screen, a battery, and the ability to make calls. Instead of redesigning those parts from scratch for every brand, manufacturers start with a 'base phone' blueprint and then add their own unique features on top. In Java, inheritance works exactly like that. You write a base class once, and every other class that 'inherits' from it automatically gets all its features — no copy-paste required.
Every production Java codebase you'll ever work in uses inheritance. It's not an academic exercise — it's the mechanism that lets a PaymentProcessor class share core logic with CreditCardProcessor and PayPalProcessor without duplicating a single line. When a bug is fixed in the base class, every subclass benefits instantly. That's not just convenient, it's the kind of thing that separates maintainable software from a tangled mess of duplicated code.
The problem inheritance solves is code duplication across related types. Imagine you're building an e-commerce platform. You have products: Books, Electronics, and Clothing. Every product has a name, a price, and a method to display itself. Without inheritance, you'd write those fields and methods three times, then fix bugs three times, and explain the logic to three different future teammates. Inheritance lets you define shared behaviour exactly once in a parent class and let child classes focus only on what makes them different.
By the end of this article you'll understand not just how to write the extends keyword, but why Java was designed this way, when inheritance is the right tool versus when it isn't, how method overriding actually works under the hood, and the patterns senior engineers use in real projects. You'll also know the mistakes that trip up even experienced developers — and how to avoid them.
What Inheritance Actually Does — The extends Keyword Unpacked
When one class extends another, the child class inherits every non-private field and method from the parent. That means the child class can use them as if it had written them itself. The parent is often called the superclass or base class; the child is called the subclass or derived class.
Java uses single inheritance for classes — each class can only extend one parent. This is a deliberate design choice to avoid the 'Diamond Problem' (where two parents both define the same method and the child doesn't know which one to use). If you need behaviour from multiple sources, Java's answer is interfaces — but that's a separate topic.
Here's the critical insight most tutorials skip: inheritance models an IS-A relationship. A Dog IS-A Animal. A SavingsAccount IS-A BankAccount. If you can't make that sentence sound natural, you probably shouldn't be using inheritance — you should be using composition instead. Inheritance locks in a tight relationship between two classes, so getting the design right from the start saves you painful refactors later.
BankAccountDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
// A realistic banking example showing why inheritance saves us from duplication// PARENT CLASS — defines everything ALL bank accounts shareclassBankAccount {
private String accountHolder; // private: only accessible within this class
protected double balance; // protected: accessible to subclassespublicBankAccount(String accountHolder, double initialBalance) {
this.accountHolder = accountHolder;
this.balance = initialBalance;
}
// Every account type can deposit money — logic lives here ONCEpublicvoiddeposit(double amount) {
if (amount <= 0) {
System.out.println("Deposit amount must be positive.");
return;
}
balance += amount;
System.out.println(accountHolder + " deposited $" + amount + ". New balance: $" + balance);
}
publicdoublegetBalance() {
return balance;
}
publicStringgetAccountHolder() {
return accountHolder;
}
publicvoidprintSummary() {
System.out.println("Account: " + accountHolder + " | Balance: $" + balance);
}
}
// CHILD CLASS — inherits everything from BankAccount, adds its own rulesclassSavingsAccountextendsBankAccount {
private double interestRate; // unique to SavingsAccountpublicSavingsAccount(String accountHolder, double initialBalance, double interestRate) {
// super() calls the PARENT constructor — must be the first linesuper(accountHolder, initialBalance);
this.interestRate = interestRate;
}
// Behaviour unique to savings accounts — the parent has no concept of thispublicvoidapplyMonthlyInterest() {
double interest = balance * interestRate;
balance += interest; // 'balance' is accessible because it's protected in parentSystem.out.println("Interest of $" + String.format("%.2f", interest) + " applied. New balance: $" + String.format("%.2f", balance));
}
}
// ANOTHER CHILD CLASS — same parent, different specialisationclassCheckingAccountextendsBankAccount {
privatedouble overdraftLimit;
publicCheckingAccount(String accountHolder, double initialBalance, double overdraftLimit) {
super(accountHolder, initialBalance);
this.overdraftLimit = overdraftLimit;
}
// Checking accounts allow withdrawal up to the overdraft limitpublicvoidwithdraw(double amount) {
if (amount > balance + overdraftLimit) {
System.out.println("Withdrawal denied: exceeds overdraft limit.");
} else {
balance -= amount;
System.out.println(getAccountHolder() + " withdrew $" + amount + ". Balance: $" + String.format("%.2f", balance));
}
}
}
publicclassBankAccountDemo {
publicstaticvoidmain(String[] args) {
SavingsAccount savings = newSavingsAccount("Alice", 1000.00, 0.03);
savings.deposit(500.00); // inherited from BankAccount — no duplication
savings.applyMonthlyInterest(); // unique to SavingsAccount
savings.printSummary(); // inherited from BankAccountSystem.out.println("---");
CheckingAccount checking = newCheckingAccount("Bob", 200.00, 150.00);
checking.deposit(100.00); // same deposit() method, reused via inheritance
checking.withdraw(400.00); // unique to CheckingAccount
checking.printSummary(); // inherited from BankAccount
}
}
Output
Alice deposited $500.0. New balance: $1500.0
Interest of $45.00 applied. New balance: $1545.00
Account: Alice | Balance: $1545.0
---
Bob deposited $100.0. New balance: $300.0
Bob withdrew $400.0. Balance: $-100.00
Account: Bob | Balance: $-100.0
Pro Tip: protected vs private in Parent Classes
Use protected for fields you intend subclasses to access directly (like balance above). Use private for fields that should only be touched through getter/setter methods, even by subclasses. Defaulting everything to protected is a common shortcut that leaks internal state — be deliberate about it.
Production Insight
A common production pain is when a parent class removes a protected field that child classes relied on.
Compile-time errors catch it if child classes are recompiled — but if only the parent JAR is updated, runtime NoSuchFieldError can bring down services.
Rule: never remove or change access modifiers of protected members without auditing all child classes across your entire deployment.
Use interface-based contracts to decouple child classes from parent implementation details.
Key Takeaway
Inheritance models an IS-A relationship; if it sounds wrong in plain English, use composition.
Java enforces single inheritance to avoid the diamond problem — use interfaces for multiple behavioural contracts.
Always ask: "Would I be okay refactoring this parent class later?" If not, inheritance is not the answer.
thecodeforge.io
Java Inheritance Pitfalls — Parent Change Impact
Inheritance Java
Types of Inheritance in Java — Single, Multilevel, Hierarchical
Java supports three main forms of inheritance through the extends keyword. Understanding each type is crucial for designing robust class hierarchies.
1. Single Inheritance — one child class extends one parent class. This is the most common form. Example: class Dog extends Animal.
2. Multilevel Inheritance — a chain of inheritance where a class extends another class that itself extends a third class. Example: class Puppy extends Dog extends Animal. While allowed, deep multilevel hierarchies are brittle in production.
3. Hierarchical Inheritance — multiple child classes extend the same parent class. Example: class SavingsAccount extends BankAccount and class CheckingAccount extends BankAccount. This models one-to-many specialisation.
Java does not support multiple inheritance (one class extending multiple classes) to avoid the diamond problem. The diagram below visualises these three types.
A class can only extend one parent class. This prevents the diamond problem. Use interfaces for multiple inheritance of type.
Production Insight
Multilevel inheritance beyond three levels is a frequent source of production incidents. Changing a grandparent method can silently break behaviour in distant subclasses. In one incident, a team added a validation step in a base class constructor, which broke child classes that had never overridden anything — simply because they relied on the old initialization order. Keep hierarchies shallow (max 2–3 levels).
Key Takeaway
Use single inheritance by default. Multilevel hierarchies are risky and require careful testing. Hierarchical inheritance is safe and common. Always question whether a deep chain is necessary.
Types of Inheritance
Advantages and Disadvantages of Inheritance
Inheritance is a powerful tool but comes with trade-offs. The table below summarises the key advantages and disadvantages you must consider before deciding to use inheritance in your design.
Advantage
Disadvantage
Code reusability — write once, use in all subclasses
Tight coupling — changes in parent can break children
Polymorphism — treat different objects uniformly via parent reference
Fragile base class — parent changes require auditing all children
Single inheritance limitation — can't inherit from multiple classes
Easy extension — add new subclasses without modifying existing code
Deep hierarchies become complex and hard to debug
Method overriding enables custom behavior
Can expose implementation details via protected members
Built into Java language — no extra libraries needed
May lead to less flexibility than composition for changing requirements
Each advantage must be weighed against the corresponding disadvantage in your specific context. The tighter the coupling, the more careful you must be when maintaining the hierarchy.
Balance is Key
Inheritance is not inherently bad. Use it when the IS-A relationship is clear and the hierarchy is shallow. For any other case, consider composition first.
Production Insight
The tight coupling disadvantage is the most common source of inheritance-related production incidents. In a project I audited, a base class change intended to fix a security vulnerability broke 12 child classes because they overrode methods and the new parent method added a pre-condition they didn't expect. The fix took three weeks of rework across teams. Always document the contract for overridable methods.
Key Takeaway
Weigh advantages against disadvantages before using inheritance. Use it when code reuse and polymorphic behaviour outweigh the cost of coupling. Prefer composition for flexibility.
Using instanceof Pattern Matching with Sealed Classes (Java 16+)
Java 16 introduced pattern matching for instanceof, allowing you to cast and bind a variable in one step. When combined with sealed classes, you get exhaustive pattern matching that makes type-safe dispatches concise and compiler-verified.
Sealed classes (introduced in Java 17 as a standard feature) let you restrict which classes can extend a given parent. This is a game-changer for controlling inheritance in production — no more unexpected subclasses sneaking in from other modules.
The following example models a shape hierarchy with pattern matching in a switch expression. The compiler enforces that all permitted subclasses are covered, eliminating the risk of missing a case.
Before Java 16, you'd write: if (shape instanceof Circle) { Circle c = (Circle) shape; ... }. Now you combine the check and cast into one line: case Circle c -> ...
Production Insight
Sealed classes prevent rogue subclasses from being created in other modules, a common source of 'instanceof' blow-ups in large codebases. Combined with pattern matching, you get exhaustive coverage enforced at compile time. In a microservice architecture, this is invaluable — you control exactly which subclasses exist and the compiler catches missing cases before deployment.
Key Takeaway
Use sealed classes to tightly control inheritance hierarchies. Pair with pattern matching for safe, concise, and exhaustive type dispatches.
Method Overriding — Giving Inherited Behaviour Your Own Spin
Inheritance lets you reuse a method, but overriding lets you replace it with a better version specific to the child class. This is where inheritance really earns its keep in real projects.
To override a method, the child class defines a method with the exact same name, return type, and parameter list as the parent. Java's @Override annotation isn't strictly required — but you should always use it. It tells the compiler 'I intend to override a parent method here.' If you make a typo in the method name and it doesn't match the parent, the compiler will catch it and throw an error. Without @Override, it silently creates a new method instead, which is a nasty bug to track down.
The super keyword is your escape hatch. Inside an overriding method, super.methodName() calls the parent's original version. This is incredibly useful when you want to extend the parent's behaviour rather than completely replace it — think logging, validation, or adding a pre-step before the parent's core logic runs.
Under the hood, this works through dynamic dispatch: when you call a method on an object, Java looks at the actual runtime type of the object — not the declared type — to decide which version to run. This is the foundation of polymorphism.
ShippingCalculatorDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// Real-world example: a shipping system where each carrier calculates cost differentlyclassShippingCarrier {
protectedString carrierName;
protecteddouble baseRatePerKg;
publicShippingCarrier(String carrierName, double baseRatePerKg) {
this.carrierName = carrierName;
this.baseRatePerKg = baseRatePerKg;
}
// Default shipping cost — child classes will override thispublicdoublecalculateShippingCost(double weightKg, double distanceKm) {
return baseRatePerKg * weightKg;
}
publicvoidprintQuote(double weightKg, double distanceKm) {
// Calls calculateShippingCost() — at runtime, Java runs the CHILD's version// if this object is actually a child instance (dynamic dispatch in action)double cost = calculateShippingCost(weightKg, distanceKm);
System.out.println(carrierName + " quote: $" + String.format("%.2f", cost));
}
}
classExpressCarrierextendsShippingCarrier {
privatedouble expressMultiplier;
publicExpressCarrier(String carrierName, double baseRatePerKg, double expressMultiplier) {
super(carrierName, baseRatePerKg);
this.expressMultiplier = expressMultiplier;
}
@Override// Always use this — protects you from typo bugspublicdoublecalculateShippingCost(double weightKg, double distanceKm) {
// Call the parent's calculation first, then add the express premiumdouble baseCost = super.calculateShippingCost(weightKg, distanceKm);
return baseCost * expressMultiplier;
}
}
classFreightCarrierextendsShippingCarrier {
privatedouble costPerKm;
publicFreightCarrier(String carrierName, double baseRatePerKg, double costPerKm) {
super(carrierName, baseRatePerKg);
this.costPerKm = costPerKm;
}
@OverridepublicdoublecalculateShippingCost(double weightKg, double distanceKm) {
// Freight pricing is completely different — weight AND distance matterdouble weightCost = baseRatePerKg * weightKg;
double distanceCost = costPerKm * distanceKm;
return weightCost + distanceCost;
}
}
publicclassShippingCalculatorDemo {
publicstaticvoidmain(String[] args) {
double packageWeight = 5.0; // kg
double shippingDistance = 200; // km// Declared as ShippingCarrier, but each object is a different subtype// printQuote() calls the CORRECT calculateShippingCost() for each — that's polymorphismShippingCarrier standardCarrier = newShippingCarrier("PostCo Standard", 2.50);
ShippingCarrier expressCarrier = newExpressCarrier("QuickShip Express", 2.50, 2.0);
ShippingCarrier freightCarrier = newFreightCarrier("HeavyHaul Freight", 1.80, 0.05);
standardCarrier.printQuote(packageWeight, shippingDistance);
expressCarrier.printQuote(packageWeight, shippingDistance); // runs ExpressCarrier's override
freightCarrier.printQuote(packageWeight, shippingDistance); // runs FreightCarrier's override
}
}
Output
PostCo Standard quote: $12.50
QuickShip Express quote: $25.00
HeavyHaul Freight quote: $19.00
Watch Out: Forgetting @Override Costs You Hours
If you write public double calculateshippingCost(...) (lowercase 's') without @Override, Java won't error — it'll quietly create a second, separate method. Your parent's printQuote() will keep calling the original version and you'll spend an afternoon confused about why your override has no effect. Always use @Override. Always.
Production Insight
I've seen a production outage caused by a developer renaming a method in the parent class but forgetting to update a single child class that had @Override — the compiler caught it. Another team had no @Override annotations and three child classes silently hid the method with different signatures.
Result: the core business logic used a different implementation for those three child classes, causing incorrect pricing for 10% of orders.
Rule: add a CI checkstyle rule that requires @Override on all overriding methods — it's the cheapest insurance you'll get.
Key Takeaway
Use @Override on every overriding method — it's not optional, it's your safety net.
super.methodName() lets you extend parent behaviour, not replace it entirely.
Dynamic dispatch ensures the correct overridden method runs based on the actual object type, not the reference type.
The super Keyword and Constructor Chaining — What Really Happens at Object Creation
When you create a child class object, Java doesn't just run the child's constructor. It runs the parent's constructor first. Every time. This guarantees the parent's portion of the object is fully set up before the child tries to build on top of it.
If you explicitly call super(...) with arguments, it forwards those arguments to the matching parent constructor. If you don't call super() at all, Java silently inserts a call to the parent's no-argument constructor. If that no-argument constructor doesn't exist in the parent, you get a compile error — which confuses a lot of people the first time they see it.
super also lets you call parent methods (not just constructors) from inside the child. This is the key to the 'extend, don't replace' pattern: call super.someMethod() to run the parent's logic, then add your own lines after it. This pattern is everywhere in Android development, Spring Framework lifecycle methods, and GUI toolkits.
One hard rule you must know:super() must be the first statement in a constructor. You can't run any other code, set any fields, or do any checks before calling it. This is enforced by the compiler, not just a convention.
EmployeeHierarchyDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// Demonstrates constructor chaining and super method calls in a real HR systemclassEmployee {
privateString fullName;
privateString employeeId;
privatedouble annualSalary;
publicEmployee(String fullName, String employeeId, double annualSalary) {
this.fullName = fullName;
this.employeeId = employeeId;
this.annualSalary = annualSalary;
System.out.println("[Employee constructor] Creating employee: " + fullName);
}
publicdoublecalculateAnnualBonus() {
// Base employees get 5% of salary as bonusreturn annualSalary * 0.05;
}
publicvoidprintCompensationReport() {
System.out.println("--- Compensation Report ---");
System.out.println("Name: " + fullName);
System.out.println("ID: " + employeeId);
System.out.println("Salary: $" + String.format("%.2f", annualSalary));
System.out.println("Bonus: $" + String.format("%.2f", calculateAnnualBonus()));
}
publicdoublegetAnnualSalary() {
return annualSalary;
}
}
classSeniorEngineerextendsEmployee {
privateint yearsOfExperience;
privateString specialisation;
publicSeniorEngineer(String fullName, String employeeId, double annualSalary,
int yearsOfExperience, String specialisation) {
// super() MUST be first — sets up the Employee part of this objectsuper(fullName, employeeId, annualSalary);
this.yearsOfExperience = yearsOfExperience;
this.specialisation = specialisation;
System.out.println("[SeniorEngineer constructor] Adding specialisation: " + specialisation);
}
@OverridepublicdoublecalculateAnnualBonus() {
// Start with what the parent would give, then add extra for experience
double baseBonus = super.calculateAnnualBonus(); // calls Employee's 5% logic
double experienceBonus = yearsOfExperience * 500.0; // $500 per year of experiencereturn baseBonus + experienceBonus;
}
@OverridepublicvoidprintCompensationReport() {
super.printCompensationReport(); // Print everything the parent prints first// Then append child-specific informationSystem.out.println("Specialisation: " + specialisation);
System.out.println("Experience: " + yearsOfExperience + " years");
}
}
publicclassEmployeeHierarchyDemo {
publicstaticvoidmain(String[] args) {
System.out.println("=== Creating a SeniorEngineer ===");
// Watch the order of constructor calls in the outputSeniorEngineer lead = newSeniorEngineer(
"Maya Chen", "ENG-0042", 120000.00, 8, "Distributed Systems"
);
System.out.println();
lead.printCompensationReport();
}
}
[SeniorEngineer constructor] Adding specialisation: Distributed Systems
--- Compensation Report ---
Name: Maya Chen
ID: ENG-0042
Salary: $120000.00
Bonus: $10000.00
Specialisation: Distributed Systems
Experience: 8 years
Interview Gold: Why Does the Parent Constructor Run First?
Java guarantees the parent portion of an object is fully initialised before the child can use it. If the child constructor ran first and tried to call an inherited method before the parent was set up, you'd get partially-constructed objects and unpredictable behaviour. This ordering is why super() must be the first statement — the compiler enforces what good design demands.
Production Insight
A colleague once added logging to a child constructor before the super() call, causing a compile error they couldn't figure out — they had placed a system.out.println before super(). The fix was simple but wasted 30 minutes.
In production, constructor chains matter most when you have deep hierarchies (3+ levels) with complex initialisation. A change in a grandparent constructor can silently break all descendants.
Rule: keep parent constructors simple and avoid calling overridable methods from constructors — the child object isn't fully initialised yet.
Key Takeaway
Parent constructor always runs before child constructor — super() must be the first statement.
If the parent has no no-arg constructor, you must explicitly call super(args) or you get a compile error.
Use the 'extend then add' pattern with super.method() to enhance parent behaviour without replacing it entirely.
When NOT to Use Inheritance — Composition vs Inheritance in the Real World
Inheritance is powerful, but it's one of the most overused patterns in Java. Senior engineers know that composition is often the better choice — and knowing the difference is what separates intermediate from advanced thinking.
The rule of thumb is the IS-A vs HAS-A test. A Car IS-A Vehicle — inheritance makes sense. A Car HAS-A Engine — that's composition; you don't extend Engine, you hold a reference to one. When you get this wrong, you end up with brittle class hierarchies where changing a parent class breaks child classes in unexpected ways (this is called the 'fragile base class' problem).
Another red flag is when you're extending a class just to reuse its methods, not because the child truly is a kind of that parent. Stack in the Java standard library infamously extends Vector — a Stack IS-NOT-A Vector, but Java's designers made that call for code reuse. The result? You can call get(index) on a Stack, which makes no semantic sense for a stack data structure. That's what bad inheritance looks like in production.
Use inheritance when you have a genuine IS-A relationship AND you want polymorphic behaviour (treating different subtypes through a shared parent type). Otherwise, default to composition.
CompositionVsInheritanceDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// Demonstrates the right and wrong way to model a 'NotificationService'// ============================================================// WRONG APPROACH: Extending EmailSender just to reuse its methods// A NotificationService IS-NOT-A EmailSender — it just USES one// ============================================================classEmailSender {
publicvoidsendEmail(String recipient, String subject, String body) {
System.out.println("Sending email to " + recipient + " | Subject: " + subject);
}
}
// BAD: This locks NotificationService into always being an EmailSender// You can never easily swap in a different sender, and the public API// of NotificationService now leaks all of EmailSender's methodsclassNotificationServiceBadextendsEmailSender {
publicvoidnotifyUser(String userEmail, String message) {
// Calling the inherited method — but this is NOT an IS-A relationshipsendEmail(userEmail, "Notification", message);
}
}
// ============================================================// RIGHT APPROACH: Composition — NotificationService HAS-A sender// ============================================================interfaceMessageSender {
// An interface means we can swap the implementation at any timevoidsend(String recipient, String message);
}
classEmailMessageSenderimplementsMessageSender {
@Overridepublicvoidsend(String recipient, String message) {
System.out.println("[Email] To: " + recipient + " | " + message);
}
}
classSmsMessageSenderimplementsMessageSender {
@Overridepublicvoidsend(String recipient, String message) {
System.out.println("[SMS] To: " + recipient + " | " + message);
}
}
// GOOD: NotificationService holds a reference to any MessageSender// Swapping from Email to SMS requires zero changes to this classclassNotificationServiceGood {
private MessageSender messageSender; // HAS-A relationship// Inject the sender — this is the Dependency Injection patternpublicNotificationServiceGood(MessageSender messageSender) {
this.messageSender = messageSender;
}
publicvoidnotifyUser(String recipient, String alertMessage) {
messageSender.send(recipient, alertMessage);
}
}
publicclassCompositionVsInheritanceDemo {
publicstaticvoidmain(String[] args) {
// BAD approach — tightly coupled to EmailSenderNotificationServiceBad badService = newNotificationServiceBad();
badService.notifyUser("alice@example.com", "Your order has shipped.");
System.out.println("---");
// GOOD approach — swap the sender without touching NotificationServiceGoodNotificationServiceGood emailNotifier = newNotificationServiceGood(newEmailMessageSender());
NotificationServiceGood smsNotifier = newNotificationServiceGood(newSmsMessageSender());
emailNotifier.notifyUser("alice@example.com", "Your order has shipped.");
smsNotifier.notifyUser("+1-555-0199", "Your order has shipped.");
}
}
Output
Sending email to alice@example.com | Subject: Notification
---
[Email] To: alice@example.com | Your order has shipped.
[SMS] To: +1-555-0199 | Your order has shipped.
Pro Tip: The Design Rule Senior Engineers Live By
'Favour composition over inheritance' is from the Gang of Four's Design Patterns book (1994) and it's still the most misunderstood advice in Java. It doesn't mean 'never use inheritance' — it means use inheritance only when a genuine IS-A relationship exists. If your main motivation is code reuse, reach for composition and interfaces first.
Production Insight
A production system I worked on had a three-level inheritance hierarchy for payment processing. The base class changed a method signature for a new compliance requirement, and it broke 15 child classes across 5 microservices. The deployment was rolled back, but the incident cost two days of work for the team.
The real problem was that two of the child classes didn't actually have an IS-A relationship — they just needed the same validation logic and used inheritance for convenience. That's the fragile base class problem in action.
Rule: if you're extending for code reuse alone, stop. Use composition or a utility class instead.
Key Takeaway
Apply the IS-A test before every use of extends — if it sounds wrong, use composition.
Composition gives you runtime flexibility, loose coupling, and prevents the fragile base class problem.
Interfaces provide polymorphic behaviour without the tight coupling of inheritance — use them as contracts.
When NOT to Use Inheritance — Production Scenarios Where Composition Wins
While the previous section covered the theoretical IS-A test, here are concrete production scenarios where you must avoid inheritance and choose composition instead.
1. When you need runtime flexibility — If you might need to swap the behaviour at runtime (e.g., different payment gateways), composition with an interface is the only clean way.
2. When the parent class changes frequently — A volatile base class will cause constant breakage across child classes. Use composition to insulate dependent classes.
3. When you only want code reuse — If there is no IS-A relationship, inheritance is the wrong tool. Use composition or a utility class.
4. When the hierarchy deepens — Beyond 3 levels, refactor to composition. Deep hierarchies are impossible to reason about in production debugging.
5. When you want to mock behaviour in tests — Inheritance makes mocking harder because you often need to subclass the real class. Composition with interfaces allows easy mocking.
Example scenario: A team built a notification system where every channel (Email, SMS, Push) inherited from a base NotificationChannel. Later they needed to add a Slack channel that used a completely different API. The base class's protected methods leaked internal state, forcing the Slack implementation to override many methods it didn't need. Refactoring to composition — each channel implementing a MessageSender interface — eliminated the fragile coupling overnight.
A production system I audited had a 7-level hierarchy for UI components. Any change to the base component required testing all 7 levels. The team spent three weeks refactoring to composition using a component pattern. The result: deployment times dropped by 40% and bugs related to unexpected inheritance behaviour disappeared. If you see hierarchy depth > 3 in a code review, flag it as a design smell.
Key Takeaway
When in doubt, prefer composition. It gives you flexibility, testability, and avoids fragile base class issues. Use inheritance only when the IS-A relationship is crystal clear and the hierarchy is shallow.
Inheritance Best Practices — Patterns That Survive Production
After years of debugging inheritance disasters, here are the patterns that actually work in production.
1. Keep inheritance hierarchies shallow. More than 3 levels deep is a red flag. Deep hierarchies make it impossible to understand which version of a method runs without tracing through every level. Senior engineers refactor deep trees into composition.
2. Design parent classes for extension — or prohibit it. Mark classes as final if they're not designed to be inherited. This is a strong signal to the next developer. Similarly, mark methods as final if they should never be overridden (like critical security checks).
3. Use the Template Method pattern. Define a skeleton algorithm in the parent class with abstract steps, and let child classes implement those steps. This keeps control with the parent while allowing variation. Spring's JdbcTemplate is a famous example.
4. Never call overridable methods from a constructor. As discussed earlier, the child object isn't fully initialised when the parent constructor runs. If the overridden method depends on child state, you'll get null or unexpected behaviour.
5. Document the contract. Use Javadoc to specify the purpose of each method, especially which ones are designed to be overridden and what the override must guarantee (e.g., always call super).
The parent's export() method is declared final so subclasses can't change the algorithm order. Child classes only implement the abstract steps. This is a clean use of inheritance — the parent provides structure, children provide variation.
Production Insight
A team used the Template Method pattern for a data export pipeline. A developer accidentally overrode the export method in a child class, skipping validation. Data corruption went unnoticed for weeks because logs showed successful exports. The fix: mark the template method as final and enforce code review. This pattern reduces inheritance misuse by limiting what children can change.
Key Takeaway
Use Template Method when you want to reuse algorithm structure while allowing variation in specific steps.
Mark template methods final to prevent accidental override of the skeleton.
Keep hierarchies shallow and document the contract for overridable methods.
What You Can Actually Do in a Subclass — and What You Can't
Inheritance isn't a free-for-all. When you slap extends on a class, you get access to public and protected members — but private members stay locked in the superclass. Period. The subclass can't see them, touch them, or override them. If you need a subclass to modify private state, you expose it through protected getters and setters, or you rethink your design.
You can add new fields, new methods, override existing ones, or hide static methods (don't hide static methods — that's a code smell 9 times out of 10). You cannot shrink the visibility of an overridden method — if the parent says protected, your override can't go private. That breaks the Liskov Substitution Principle, and your compiler will slap you.
Constructors are not inherited. If the superclass only has a parameterized constructor, your subclass must call it via super(...) as the first statement in its own constructor. Miss that, and the compiler refuses to compile. This isn't a suggestion — it's a hard rule enforced at compile time.
Stripe charging $2999 via https://api.stripe.com/v1
Production Trap:
Never make a field protected just so a subclass can access it directly. That breaks encapsulation. Use protected accessor methods. I've seen a protected field cascade into a tangled mess of mutations across five subclasses. Don't be that dev.
Key Takeaway
Subclasses inherit public and protected members. Private members stay hidden. Constructors are not inherited — call super() explicitly or prepare for compile errors.
Multiple Inheritance Through Interfaces — Java's Boring but Safe Escape Hatch
Java doesn't allow multiple class inheritance. A class can't extend two classes. Why? Because of the Diamond Problem — if two parent classes define the same method, which one does the child inherit? C++ says good luck, Java says no thanks.
But you can implement multiple interfaces. That's Java's compromise. An interface defines a contract — no state, just abstract methods (before Java 8) or default methods (since Java 8). If two interfaces define a default method with the same signature, the implementing class must override it, or the compiler screams. That's the diamond problem handled explicitly, not silently.
Real-world example: an InvoiceService can implement both Exportable and Auditable. Each interface declares a behavior. The class implements both, and if there's a conflict between default methods — export() returning CSV vs JSON — you resolve it in the class. It's verbose. It's safe. It's Java.
Prefer interfaces with default methods over abstract classes when you need shared behavior across unrelated classes. Abstract classes lock you into a hierarchy. Interfaces let you mix behaviors without the coupling. Save extends for 'is-a' relationships, not 'has-a'.
Key Takeaway
Java prohibits multiple class inheritance to avoid the Diamond Problem. Use interfaces for multiple behaviors — resolve default method conflicts explicitly, never silently.
● Production incidentPOST-MORTEMseverity: high
When a Parent Method Change Broke 30 Child Classes in Production
Symptom
After deploying a seemingly minor update to the base DocumentHandler class, several document processing pipelines started throwing ClassCastException or silently failing to save documents. Logs showed 'java.lang.VerifyError: Expecting a stackmap frame' or unexpected behaviour in overridden methods.
Assumption
The assumption was that changing the return type of a method that was not referenced by any child class would be safe — the team believed child classes only depended on the method's side effects.
Root cause
The parent's method signature changed, but child classes that overrode the method still had the old signature. Java's method dispatch uses the exact signature at compile time; at runtime, the JVM found a mismatch and threw a linkage error. Additionally, some child classes used @Override which caught the mismatch — but two classes had no @Override and silently created new methods, causing those pipelines to call the parent version instead of their intended logic.
Fix
Reverted the deployment. Used the @Override annotation on every override in the codebase (added a checkstyle rule). Then changed the parent method to add a new overloaded method instead of modifying the existing one, preserving backward compatibility. The new method was added with a default implementation that called the old method, and child classes were updated over two sprints.
Key lesson
Never change the signature of a public or protected method in a parent class without a full audit of all child classes.
Always use @Override on every overridden method — it's a compile-time safety net against signature drift.
Prefer adding new overloaded methods over modifying existing ones to preserve backward compatibility.
Use the Open/Closed Principle: parent classes should be open for extension but closed for modification once released.
Production debug guideCommon symptoms and immediate actions when inheritance behaviour breaks4 entries
Symptom · 01
Overridden method is not being called; parent method runs instead
→
Fix
Check if the child class actually overrides the method: verify signature matches exactly (including return type). Ensure @Override is present — without it, a typo creates a new method, not an override. Also check if the method is private (private methods are not inherited).
Symptom · 02
Constructor of child class fails to compile with 'implicit super constructor is undefined'
→
Fix
The parent class does not have a no-argument constructor. Add an explicit super(…) call in the child constructor with matching arguments, or add a no-arg constructor to the parent class.
Symptom · 03
Child class is not visible or type mismatch in polymorphic assignment
→
Fix
Check access modifiers: if the child class is package-private and the parent is public, you cannot reference the child from outside the package. Also verify the inheritance hierarchy — the child must directly or transitively extend the parent.
Symptom · 04
Method throws UnsupportedOperationException when called on a parent reference
→
Fix
The parent method might have a default implementation that throws, but the child is supposed to override it. Check all child classes for missing overrides. Use instanceof checks in debug logs to verify the runtime type.
★ Inheritance Debugging Cheat SheetQuick commands and checks for common inheritance pitfalls
Overridden method not called−
Immediate action
Add @Override to the child method and recompile — the compiler will flag signature mismatch.
Commands
javap -c -p ParentClass.class | grep methodName — check actual method signature in bytecode
Add logging: System.out.println("Running parent version") inside both parent and child methods to see which is invoked.
Fix now
Correct the method signature in the child to exactly match the parent (including return type). Ensure the method is not private in the parent.
Parent constructor not executed+
Immediate action
Verify the child constructor calls super(args) as the very first statement. No other code before super().
Commands
Add a print statement in the parent constructor and child constructor to confirm execution order.
Check if parent class has a no-arg constructor; if not, the child must explicitly call super(…).
Fix now
Add super(requiredArgs) as first line in child constructor. If parent has no no-arg constructor, add one (or always call super explicitly).
Polymorphic call invokes wrong method after refactoring+
Immediate action
Check if any child classes lost their @Override after refactoring. Run a grep for 'implements' and 'extends' to verify hierarchy.
Commands
Use IDE feature 'Show Hierarchy' (Ctrl+H in Eclipse, Ctrl+T in IntelliJ) to visualise overrides.
Run 'mvn compile' or 'gradle compileJava' with -Xlint:all to get warnings about missing @Override.
Fix now
Add @Override annotation. If the method was removed from parent, add it back or change the child to match the new hierarchy.
Inheritance vs Composition
Aspect
Inheritance
Composition
Relationship
IS-A (tight coupling)
HAS-A (loose coupling)
Code reuse
Reuse via extension, limited to one parent
Reuse via delegation, flexible and composable
Runtime flexibility
Fixed at compile time
Can swap behaviours at runtime via dependency injection
Testability
Harder to mock, often requires subclassing
Easy to mock by injecting mock implementations
Encapsulation
Leaks parent implementation details via protected members
Encapsulates behaviour behind interfaces
Fragility
Fragile base class problem — parent changes break children
Changes to composed objects are isolated
Polymorphism
Natural via method overriding
Achieved through interfaces and delegation
Key takeaways
1
Inheritance models an IS-A relationship; if it doesn't sound right, use composition.
2
Always add @Override to every overriding method
it's your compile-time safety net.
3
Parent constructors run first; super() must be the first statement in a child constructor.
4
Dynamic dispatch ensures the correct overridden method runs based on the actual runtime type.
5
Keep inheritance hierarchies shallow (max 3 levels) to avoid the fragile base class problem.
6
Prefer composition over inheritance for code reuse unless a genuine IS-A relationship exists.
7
Sealed classes (Java 17+) let you control which classes can extend a parent, reducing unexpected subclasses.
Common mistakes to avoid
5 patterns
×
Forgetting @Override annotation
Symptom
Child method silently becomes a new method instead of overriding the parent. Polymorphic calls invoke the parent logic, leading to incorrect behaviour or missing functionality.
Fix
Always add @Override to methods intended to override. Configure your IDE to warn if @Override is missing and enforce it in CI with Checkstyle or SpotBugs.
×
Calling overridable methods from a constructor
Symptom
When the overridden method is invoked during object construction, it may depend on child class fields that are not yet initialised, resulting in NullPointerException or unexpected default values.
Fix
Never call overridable methods in constructors. If you must call such logic, use a factory method or postpone initialization to an init() method called after construction.
×
Using inheritance for code reuse when no IS-A relationship exists
Symptom
Brittle hierarchy: a change in the parent class breaks multiple unrelated child classes. Testing and debugging become harder because inherited methods expose unintended behaviour.
Fix
Apply the IS-A test. If the relationship is not clear, use composition (HAS-A) instead. Extract shared logic into a utility class or inject via an interface.
×
Removing or changing a protected method/field in a parent class without auditing children
Symptom
Child classes fail to compile if recompiled, or worse, runtime errors like NoSuchMethodError or IncompatibleClassChangeError when only the parent JAR is updated.
Fix
Before changing protected members, audit all known child classes. Prefer adding new overloaded methods over modifying existing ones. Use a deprecation cycle with @Deprecated before removal.
×
Not using the @Override annotation when overriding
Symptom
If the parent method signature changes (e.g., parameter type), the child method no longer overrides it and becomes an independent method. Polymorphic calls stop working as expected.
Fix
Always use @Override. It causes a compile error if the method does not actually override a parent method, catching signature drift early.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between method overloading and method overriding ...
Q02SENIOR
Why does Java not support multiple inheritance of classes?
Q03SENIOR
Explain the 'fragile base class' problem and how to mitigate it in produ...
Q01 of 03JUNIOR
What is the difference between method overloading and method overriding in Java?
ANSWER
Overloading occurs when multiple methods in the same class have the same name but different parameter lists. It is resolved at compile time (static polymorphism). Overriding occurs when a subclass provides a specific implementation of a method already defined in its superclass. It is resolved at runtime based on the object's actual type (dynamic polymorphism). Overriding requires the same signature and return type (or covariant return type) and cannot be done on private, static, or final methods. Overloading does not have these restrictions.
Q02 of 03SENIOR
Why does Java not support multiple inheritance of classes?
ANSWER
Multiple inheritance can lead to the Diamond Problem, where two parent classes define the same method and the child class cannot determine which one to inherit. Java avoids this by allowing only single inheritance for classes. Instead, multiple inheritance of type is achieved through interfaces, which do not carry method implementations (until default methods) and thus avoid ambiguity. If two interfaces provide the same default method, the implementing class must override it to resolve the conflict.
Q03 of 03SENIOR
Explain the 'fragile base class' problem and how to mitigate it in production Java applications.
ANSWER
The fragile base class problem occurs when changes to a parent class (e.g., adding a new method, changing logic, or altering the constructor) break child classes that were not expecting the change, even if the child did not modify the relevant code. This is especially problematic in large codebases with many subclasses. Mitigations include: (1) Design parent classes as abstract or with final methods to limit extension points, (2) Use the Template Method pattern to control algorithm flow, (3) Document the contract for overridable methods, (4) Prefer composition over inheritance to reduce coupling, (5) Enforce @Override on all overrides and run extensive integration tests when changing parent classes.
01
What is the difference between method overloading and method overriding in Java?
JUNIOR
02
Why does Java not support multiple inheritance of classes?
SENIOR
03
Explain the 'fragile base class' problem and how to mitigate it in production Java applications.
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
Can a private method be overridden in Java?
No. Private methods are not inherited by subclasses, so they cannot be overridden. If a subclass defines a method with the same name and signature as a private method in the parent, it is a completely new method, not an override.
Was this helpful?
02
What is the difference between 'super' and 'this' in the context of inheritance?
'this' refers to the current object instance, while 'super' refers to the parent class context. 'super' is used to call the parent class constructor (super()) or to call a parent class method that has been overridden (super.methodName()). 'this' is used to refer to the current object's fields or methods.
Was this helpful?
03
Is it possible to extend a final class?
No. A final class cannot be subclassed. This is used to prevent inheritance, for example, for security or immutability reasons. String, Integer, and other wrapper classes are final.