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
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
// 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) {\n this.accountHolder = accountHolder;\n this.balance = initialBalance;\n }
// 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 rules
class SavingsAccount extends BankAccount {\n private double interestRate; // unique to SavingsAccount\n\n public SavingsAccount(String accountHolder, double initialBalance, double interestRate) {\n // super() calls the PARENT constructor — must be the first line\n super(accountHolder, initialBalance);\n this.interestRate = interestRate;\n }// 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 {\n privatedouble overdraftLimit;\n\n publicCheckingAccount(String accountHolder, double initialBalance, double overdraftLimit) {\n super(accountHolder, initialBalance);\n this.overdraftLimit = overdraftLimit;\n }
// 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));
}
}
}
public class BankAccountDemo {\n public static void main(String[] args) {\n SavingsAccount savings = new SavingsAccount(\"Alice\", 1000.00, 0.03);\n savings.deposit(500.00); // inherited from BankAccount — no duplication\n savings.applyMonthlyInterest(); // unique to SavingsAccount\n savings.printSummary(); // inherited from BankAccount\n\n System.out.println(\"---\");\n\n CheckingAccount checking = new CheckingAccount(\"Bob\", 200.00, 150.00);\n checking.deposit(100.00); // same deposit() method, reused via inheritance\n checking.withdraw(400.00); // unique to CheckingAccount\n checking.printSummary(); // inherited from BankAccount\n }\n}","output": "Alice deposited $500.0. New balance: $1500.0\nInterest of $45.00 applied. New balance: $1545.00\nAccount: Alice | Balance: $1545.0\n---\nBob deposited $100.0. New balance: $300.0\nBob withdrew $400.0. Balance: $-100.00\nAccount: Bob | Balance: $-100.0"
}
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.
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
// Real-world example: a shipping system where each carrier calculates cost differentlyclassShippingCarrier {
protectedString carrierName;
protecteddouble baseRatePerKg;
publicShippingCarrier(String carrierName, double baseRatePerKg) {\n this.carrierName = carrierName;\n this.baseRatePerKg = baseRatePerKg;\n }
// Default shipping cost — child classes will override thispublicdoublecalculateShippingCost(double weightKg, double distanceKm) {\n return baseRatePerKg * weightKg;\n }
public void printQuote(double weightKg, double distanceKm) {\n // Calls calculateShippingCost() — at runtime, Java runs the CHILD's version\n // if this object is actually a child instance (dynamic dispatch in action)\n double cost = calculateShippingCost(weightKg, distanceKm);\n System.out.println(carrierName + \" quote: $\" + String.format(\"%.2f\", cost));\n }\n}\n\nclass ExpressCarrier extends ShippingCarrier {\n private double expressMultiplier;\n\n public ExpressCarrier(String carrierName, double baseRatePerKg, double expressMultiplier) {\n super(carrierName, baseRatePerKg);\n this.expressMultiplier = expressMultiplier;\n }\n\n @Override // Always use this — protects you from typo bugs\n public double calculateShippingCost(double weightKg, double distanceKm) {\n // Call the parent's calculation first, then add the express premium\n double baseCost = super.calculateShippingCost(weightKg, distanceKm);\n return baseCost * expressMultiplier;\n }
}
classFreightCarrierextendsShippingCarrier {
privatedouble costPerKm;
publicFreightCarrier(String carrierName, double baseRatePerKg, double costPerKm) {\n super(carrierName, baseRatePerKg);\n this.costPerKm = costPerKm;\n }
@Override
public double calculateShippingCost(double weightKg, double distanceKm) {\n // Freight pricing is completely different — weight AND distance matter\n double weightCost = baseRatePerKg * weightKg;\n double distanceCost = costPerKm * distanceKm;\n return weightCost + distanceCost;\n }
}
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
// Demonstrates constructor chaining and super method calls in a real HR systemclassEmployee {
privateString fullName;
privateString employeeId;
privatedouble annualSalary;
public Employee(String fullName, String employeeId, double annualSalary) {\n this.fullName = fullName;\n this.employeeId = employeeId;\n this.annualSalary = annualSalary;\n System.out.println(\"[Employee constructor] Creating employee: \" + fullName);\n }\n\n public double calculateAnnualBonus() {\n // Base employees get 5% of salary as bonus\n return annualSalary * 0.05;\n }\n\n public void printCompensationReport() {\n System.out.println(\"--- Compensation Report ---\");\n System.out.println(\"Name: \" + fullName);\n System.out.println(\"ID: \" + employeeId);\n System.out.println(\"Salary: $\" + String.format(\"%.2f\", annualSalary));\n System.out.println(\"Bonus: $\" + String.format(\"%.2f\", calculateAnnualBonus()));\n }\n\n public double getAnnualSalary() {\n return annualSalary;\n }\n}\n\nclass SeniorEngineer extends Employee {\n private int yearsOfExperience;\n private String specialisation;\n\n public SeniorEngineer(String fullName, String employeeId, double annualSalary,\n int yearsOfExperience, String specialisation) {\n // super() MUST be first — sets up the Employee part of this object\n super(fullName, employeeId, annualSalary);\n this.yearsOfExperience = yearsOfExperience;\n this.specialisation = specialisation;\n System.out.println(\"[SeniorEngineer constructor] Adding specialisation: \" + specialisation);\n }\n\n @Override\n public double calculateAnnualBonus() {\n // Start with what the parent would give, then add extra for experience\n double baseBonus = super.calculateAnnualBonus(); // calls Employee's 5% logic\n double experienceBonus = yearsOfExperience * 500.0; // $500 per year of experience\n return baseBonus + experienceBonus;\n }\n\n @Override\n public void printCompensationReport() {\n super.printCompensationReport(); // Print everything the parent prints first\n // Then append child-specific information\n System.out.println(\"Specialisation: \" + specialisation);\n System.out.println(\"Experience: \" + yearsOfExperience + \" years\");\n }\n}\n\npublic class EmployeeHierarchyDemo {\n public static void main(String[] args) {\n System.out.println(\"=== Creating a SeniorEngineer ===\");\n // Watch the order of constructor calls in the output\n SeniorEngineer lead = new SeniorEngineer(\n \"Maya Chen\", \"ENG-0042\", 120000.00, 8, \"Distributed Systems\"\n );\n\n System.out.println();\n lead.printCompensationReport();\n }\n}","output": "=== Creating a SeniorEngineer ===\n[Employee constructor] Creating employee: Maya Chen\n[SeniorEngineer constructor] Adding specialisation: Distributed Systems\n\n--- Compensation Report ---\nName: Maya Chen\nID: ENG-0042\nSalary: $120000.00\nBonus: $10000.00\nSpecialisation: Distributed Systems\nExperience: 8 years"
}
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
// 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 {
public void sendEmail(String recipient, String subject, String body) {\n System.out.println(\"Sending email to \" + recipient + \" | Subject: \" + subject);\n }\n}\n\n// BAD: This locks NotificationService into always being an EmailSender\n// You can never easily swap in a different sender, and the public API\n// of NotificationService now leaks all of EmailSender's methods\nclass NotificationServiceBad extends EmailSender {\n public void notifyUser(String userEmail, String message) {\n // Calling the inherited method — but this is NOT an IS-A relationship\n sendEmail(userEmail, \"Notification\", message);\n }\n}\n\n// ============================================================\n// RIGHT APPROACH: Composition — NotificationService HAS-A sender\n// ============================================================\ninterface MessageSender {\n // An interface means we can swap the implementation at any time\n void send(String recipient, String message);\n}\n\nclass EmailMessageSender implements MessageSender {\n @Override\n public void send(String recipient, String message) {\n System.out.println(\"[Email] To: \" + recipient + \" | \" + message);\n }\n}\n\nclass SmsMessageSender implements MessageSender {\n @Override\n public void send(String recipient, String message) {\n System.out.println(\"[SMS] To: \" + recipient + \" | \" + message);\n }\n}\n\n// GOOD: NotificationService holds a reference to any MessageSender\n// Swapping from Email to SMS requires zero changes to this class\nclass NotificationServiceGood {\n private MessageSender messageSender; // HAS-A relationship\n\n // Inject the sender — this is the Dependency Injection pattern\n public NotificationServiceGood(MessageSender messageSender) {\n this.messageSender = messageSender;\n }\n\n public void notifyUser(String recipient, String alertMessage) {\n messageSender.send(recipient, alertMessage);\n }\n}\n\npublic class CompositionVsInheritanceDemo {\n public static void main(String[] args) {\n // BAD approach — tightly coupled to EmailSender\n NotificationServiceBad badService = new NotificationServiceBad();\n badService.notifyUser(\"alice@example.com\", \"Your order has shipped.\");\n\n System.out.println(\"---\");\n\n // GOOD approach — swap the sender without touching NotificationServiceGood\n NotificationServiceGood emailNotifier = new NotificationServiceGood(new EmailMessageSender());\n NotificationServiceGood smsNotifier = new NotificationServiceGood(new SmsMessageSender());\n\n emailNotifier.notifyUser(\"alice@example.com\", \"Your order has shipped.\");\n smsNotifier.notifyUser(\"+1-555-0199\", \"Your order has shipped.\");\n }\n}","output": "Sending email to alice@example.com | Subject: Notification\n---\n[Email] To: alice@example.com | Your order has shipped.\n[SMS] To: +1-555-0199 | Your order has shipped."
}
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.
RefactorToCompositionDemo.javaJAVA
1
2
3
4
5
6
7
8
9
// Instead of extending a heavy base class, compose with an interfaceinterfaceMessageSender {\n voidsend(String recipient, String message);\n}
classEmailSenderimplementsMessageSender {
@Override
public void send(String recipient, String message) {\n System.out.println(\"[Email] \" + recipient + \": \" + message);\n }\n}\n\nclass SmsSender implements MessageSender {\n @Override\n public void send(String recipient, String message) {\n System.out.println(\"[SMS] \" + recipient + \": \" + message);\n }\n}\n\nclass SlackSender implements MessageSender {\n @Override\n public void send(String channel, String message) {\n System.out.println(\"[Slack] #\" + channel + \": \" + message);\n }\n}\n\nclass NotificationService {\n private MessageSender sender; // composition\n\n public NotificationService(MessageSender sender) {\n this.sender = sender;\n }\n\n public void notify(String recipient, String msg) {\n sender.send(recipient, msg);\n }\n}","output": ""
}
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).
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.