Inheritance in Java: How It Works and When to Use It
backend
13 min read
A senior dev's take on inheritance in Java - how extends works, when to reach for it, when to avoid it, and why composition usually wins in real code.

Published By: Nelson Djalo | Date: April 29, 2026
Every Java tutorial introduces inheritance with a Dog extends Animal example, and every junior dev walks away thinking inheritance is the way you reuse code. A few years and a few legacy codebases later, you realise inheritance in Java is more often the source of pain than the cure for it. The keyword is easy. Knowing when not to use it is the hard part.
This post walks through how inheritance actually works in Java - the mechanics of extends, super, and method overriding - and then spends most of its time on the question that matters in real code: when should you reach for inheritance, and when should you reach for composition instead.
Inheritance lets one class take on the fields and methods of another. The child class gets everything non-private from the parent for free, and can add new behaviour or change existing behaviour on top.
The textbook framing is "is-a." A Manager is an Employee, so Manager extends Employee. A Car is a Vehicle, so Car extends Vehicle. That framing is fine as a memory aid, but it hides the real cost: when a child extends a parent, it inherits not just the parent's behaviour but also its constraints, its bugs, and its future changes. You're not borrowing code, you're entering into a long-term contract.
In Java, inheritance is single - a class can extend exactly one other class. There are no diamonds, no multiple parents, no surprise method resolution. That restriction is deliberate. The language designers learned from C++ that multi-parent inheritance creates more problems than it solves. If you need to mix in behaviour from several places, you use interfaces, and since Java 8, default methods on those interfaces.
public class Employee {
protected String name;
protected BigDecimal salary;
public Employee(String name, BigDecimal salary) {
this.name = name;
this.salary = salary;
}
public BigDecimal calculatePay() {
return salary;
}
}
public class Manager extends Employee {
private BigDecimal bonus;
public Manager(String name, BigDecimal salary, BigDecimal bonus) {
super(name, salary);
this.bonus = bonus;
}
@Override
public BigDecimal calculatePay() {
return salary.add(bonus);
}
}
Manager inherits name, salary, and calculatePay() from Employee. It adds a bonus field and overrides the pay calculation. That's the entire mechanic.
extends is how you declare inheritance in Java. One class, one parent, no exceptions. Every class you write implicitly extends Object if you don't specify a parent, which is why every Java object has methods like equals(), hashCode(), and toString() available out of the box.
public class Shape {
protected String colour;
public double area() {
return 0;
}
}
public class Circle extends Shape {
private double radius;
public Circle(double radius, String colour) {
this.radius = radius;
this.colour = colour;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
A Circle is a Shape. Wherever code expects a Shape, you can pass a Circle. That substitutability is the real reason inheritance exists - polymorphism. Without it, you'd just be copying fields between classes.
The thing to watch is what extends means for visibility. The child class sees everything protected and public from the parent. It does not see anything private. So if a parent class hides a field with private, the child has to go through a public or protected method to get at it.
When you extend a class, you inherit its constructors' obligations too. Java requires that the parent constructor runs before the child's body executes. If you don't write super(...) explicitly, the compiler inserts a no-arg super() call for you.
public class Employee {
private final String name;
public Employee(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name required");
}
this.name = name;
}
}
public class Manager extends Employee {
private final int reports;
public Manager(String name, int reports) {
super(name); // must be the first statement
this.reports = reports;
}
}
super(name) runs the parent's constructor first, which validates the name and sets the field. Only then does the rest of Manager's constructor run. If Employee had no no-arg constructor and you forgot to call super(name), the code wouldn't compile - the compiler refuses to silently pick a constructor for you when none exists.
super also works as a method qualifier. Inside an overriding method, super.someMethod() calls the parent's version. That's how you extend behaviour rather than replace it:
@Override
public BigDecimal calculatePay() {
return super.calculatePay().add(bonus);
}
This calls Employee.calculatePay() and adds the bonus on top. If you want to know how to apply this discipline across a real codebase, the Java for Beginners course works through inheritance, constructors, and polymorphism with the same examples a working backend dev sees every week.
Overriding is when a subclass provides its own implementation of a method declared in the parent. It's the engine behind polymorphism - you can call the same method on different objects and get different behaviour depending on the actual type.
public class Notification {
public void send(String message) {
System.out.println("Generic notification: " + message);
}
}
public class EmailNotification extends Notification {
@Override
public void send(String message) {
System.out.println("Sending email: " + message);
}
}
public class SmsNotification extends Notification {
@Override
public void send(String message) {
System.out.println("Sending SMS: " + message);
}
}
Now you can write code that doesn't care which concrete type it has:
List<Notification> notifications = List.of(
new EmailNotification(),
new SmsNotification()
);
for (Notification n : notifications) {
n.send("Build deployed");
}
Always use the @Override annotation. It's not decoration - it's a compile-time check. If you mistype the method name or get the signature wrong, the compiler will tell you instead of silently creating a new unrelated method on the subclass. That's saved more bugs than I can count.
The rules for overriding are strict. The method signature must match. The return type must be the same or a subtype (covariant returns). The access level can only stay the same or get more permissive. You can't throw new checked exceptions that the parent didn't declare. Break any of these and the compiler refuses.
This is the part most tutorials skip. Inheritance is one tool, composition is another, and senior devs reach for composition far more often.
Inheritance says "B is an A." Manager is an Employee. Circle is a Shape. You're committing to the parent's entire interface and lifecycle.
Composition says "B has an A." A Car has an Engine. A UserService has a UserRepository. You hold a reference to another object and delegate work to it.
Here's the same notification problem solved with composition:
public interface NotificationChannel {
void send(String message);
}
public class EmailChannel implements NotificationChannel {
public void send(String message) { /* ... */ }
}
public class SmsChannel implements NotificationChannel {
public void send(String message) { /* ... */ }
}
public class NotificationService {
private final NotificationChannel channel;
public NotificationService(NotificationChannel channel) {
this.channel = channel;
}
public void notify(String message) {
channel.send(message);
}
}
There's no extends anywhere. NotificationService doesn't inherit from anything - it holds a NotificationChannel and delegates. You can swap the channel at runtime, mix multiple channels, or test the service with a fake channel without touching any class hierarchy.
The rule of thumb: prefer composition unless there's a genuine "is-a" relationship and you're sure the parent's contract is stable. Even then, ask yourself whether an interface plus composition would do the same job with less coupling. The phrase "favour composition over inheritance" comes from the Gang of Four book and it's stuck around for thirty years because it keeps being right.
Abstract classes are inheritance's natural home. They exist to be extended - you can't instantiate them directly. They give you a place to put shared state and shared behaviour while forcing subclasses to fill in the missing pieces.
public abstract class PaymentProcessor {
protected final String merchantId;
protected PaymentProcessor(String merchantId) {
this.merchantId = merchantId;
}
public final PaymentResult process(Payment payment) {
validate(payment);
PaymentResult result = charge(payment);
log(payment, result);
return result;
}
protected abstract PaymentResult charge(Payment payment);
private void validate(Payment payment) { /* shared */ }
private void log(Payment p, PaymentResult r) { /* shared */ }
}
public class StripeProcessor extends PaymentProcessor {
public StripeProcessor(String merchantId) {
super(merchantId);
}
@Override
protected PaymentResult charge(Payment payment) {
// call Stripe API
}
}
process() is final so subclasses can't change the order of operations. charge() is abstract so they must implement it. This is the template method pattern, and it's one of the few places inheritance genuinely earns its keep - shared structure with controlled extension points.
Sometimes you want to stop inheritance dead. The final keyword on a class makes it un-extendable. On a method, it makes it un-overridable.
public final class ImmutablePoint {
private final double x;
private final double y;
// ...
}
String, Integer, and LocalDate are all final. They can't be subclassed because subclassing would risk breaking their immutability or value semantics. If you're writing a class whose behaviour must not be tampered with, mark it final and document why.
Final methods are useful inside non-final classes - you allow extension in general but lock down specific methods that the rest of your class depends on. The process() method in the abstract example above is a perfect case.
A blanket guideline from Effective Java: design and document for inheritance, or else prohibit it. If you haven't thought through what subclasses can and can't override, mark the class final.
Putting it together, here's a small but realistic hierarchy.
public abstract class Employee {
protected final String id;
protected final String name;
protected final BigDecimal baseSalary;
protected Employee(String id, String name, BigDecimal baseSalary) {
this.id = Objects.requireNonNull(id);
this.name = Objects.requireNonNull(name);
this.baseSalary = Objects.requireNonNull(baseSalary);
}
public abstract BigDecimal calculateMonthlyPay();
public String getName() {
return name;
}
}
public class SalariedEmployee extends Employee {
public SalariedEmployee(String id, String name, BigDecimal baseSalary) {
super(id, name, baseSalary);
}
@Override
public BigDecimal calculateMonthlyPay() {
return baseSalary.divide(BigDecimal.valueOf(12), RoundingMode.HALF_UP);
}
}
public class HourlyEmployee extends Employee {
private final BigDecimal hourlyRate;
private final int hoursWorked;
public HourlyEmployee(String id, String name, BigDecimal hourlyRate, int hoursWorked) {
super(id, name, BigDecimal.ZERO);
this.hourlyRate = hourlyRate;
this.hoursWorked = hoursWorked;
}
@Override
public BigDecimal calculateMonthlyPay() {
return hourlyRate.multiply(BigDecimal.valueOf(hoursWorked));
}
}
Employee is abstract because there's no such thing as a generic employee in this domain - every employee is salaried, hourly, or something else. The shared fields and the getName() method live in the parent. The pay calculation is abstract because it differs per type.
This works because the hierarchy is genuinely closed. Adding a new employee type means adding a new subclass, and the rest of the codebase keeps working through the Employee interface. If pay calculation rules started getting wildly different per employee - bonuses, overtime tiers, region-specific tax - you'd start regretting inheritance and refactor towards a strategy pattern with composition.
Deep hierarchies. Three or four levels of inheritance is usually a sign that someone reached for extends instead of thinking. Every extra level multiplies the surface area for changes to break things. Aim for shallow trees.
Inheriting just to reuse code. If Manager extends Employee only because both have a name field, you've coupled them for the wrong reason. Extract a helper class or use composition. Inheritance is for behaviour, not for sharing fields.
The fragile base class problem. A change in the parent class can silently break every subclass. Add a new method, change a default, modify the order of internal calls - any of these can ripple through children you forgot existed. The further the parent and child are from each other (different teams, different modules), the worse this gets.
Overriding methods that weren't designed to be overridden. If a parent class isn't documented for inheritance, overriding its methods is gambling. The parent might call other methods internally, and your override might break those calls in ways you never see in tests.
Using protected fields as a shortcut. Marking everything protected so subclasses have free access turns the parent into a bag of mutable state shared with every descendant. Prefer private fields with protected methods if you really need controlled access.
Confusing extension with implementation. If you find yourself extending a concrete class purely to reuse one method, you almost certainly want composition or a static helper, not inheritance.
extends only when there's a clear "is-a" relationship and a stable parent contract.abstract classes for genuinely shared behaviour with mandatory hooks - the template method pattern is its strongest use case.final by default. Open them up to inheritance only when you've designed and documented for it.@Override. Free compile-time check, no excuse to skip it.protected fields as a shortcut. Encapsulate properly even within the family.Inheritance in Java is a small mechanic with a big footprint. The syntax - extends, super, @Override - takes about ten minutes to learn. The judgement of when to use it takes years, and the answer most of the time is "don't."
That's not a knock on inheritance. It still earns its place in well-designed abstract classes, in genuine "is-a" hierarchies, and anywhere polymorphism makes the calling code simpler. But it's a sharp tool, and reaching for it reflexively is how codebases end up with seven-deep hierarchies that nobody dares refactor.
Get composition into your hands first, treat inheritance as the special case, and your designs will age a lot better. If you want to build that instinct properly from scratch, the Java for Beginners course works through inheritance alongside encapsulation, polymorphism, and the rest of OOP using realistic examples - so you learn not just how the keywords work, but when each one is the right call.

Skip the generic recommendations. These 9 books changed how I write code, lead teams, and think about systems - from Clean Code to books most devs haven't heard of.

The exact skills, tools, and learning order to go from zero to hired as a Java full stack developer. Covers Spring Boot, React, databases, Docker, and what employers actually look for.

Abstract classes in Java are one of the most misunderstood OOP concepts. Here's a practical guide with real-world examples, code you can actually use, and the mistakes most devs make.
Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.