Inheritance: IS-A relationships and when to avoid them
Inheritance models IS-A relationships and enables polymorphism, but it creates tight coupling. Use composition when in doubt.
Inheritance is the most frequently misused OOP mechanism. It gets taught as "code reuse," so developers reach for it whenever two classes share some fields. That produces tightly coupled hierarchies that break when base classes change. The correct test is Liskov Substitution: can every subtype stand in for the parent in every context without surprising the caller? If not, use composition.
The Problem
Inheritance used purely for code reuse, without a genuine IS-A relationship, quickly becomes fragile:
// Broken: code reuse masquerading as IS-A
public class Stack<T> extends ArrayList<T> {
// Inherits add(), addAll(), get(0), set(0, x), remove(0)...
// None of those are valid stack operations. Stacks are push/pop only.
public T push(T item) { add(item); return item; }
public T pop() { return remove(size() - 1); }
}
Stack<String> s = new Stack<>();
s.push("first");
s.push("second");
s.add(0, "injected"); // ArrayList.add(index, element) bypasses stack semantics
// Stack invariant violated: "injected" is at the bottom without a push.
This is the exact mistake in java.util.Stack extends java.util.Vector, which ships in the JDK and is considered a design error. Every senior-level interview expects you to know this example. Here is what changes when inheritance is used correctly.
Core Concept
Inheritance expresses IS-A: every operation valid on the parent must also be valid on the child. The child is a more specific version of the parent, not a container for the parent's implementation.
Department uses composition (HAS-A), not inheritance -- a department is not a type of employee. Contractor implements Billable via an interface because "can be invoiced" is a capability, not an IS-A relationship. Both Manager and Engineer pass the substitution test: you can pass either wherever an Employee is expected.
Implementation
/**
* Abstract base class for all employee types.
* Abstract because a bare "Employee" with no employment type
* does not exist in this domain β you're always a Manager, Engineer, etc.
*
* Fields here are shared by ALL subtypes without exception.
* Do not promote fields here if only some subtypes need them;
* those belong in the concrete subclass.
*/
public abstract class Employee {
private final String id;
private final String name;
private final String email;
// Protected: subclasses in the same package can read it directly.
protected String departmentId;
protected Employee(String id, String name, String email, String departmentId) {
if (id == null || id.isBlank()) throw new IllegalArgumentException("id required");
if (name == null || name.isBlank()) throw new IllegalArgumentException("name required");
this.id = id;
this.name = name;
this.email = email;
this.departmentId = departmentId;
}
// Abstract: subclasses define how they are paid.
// Template Method: generatePayslip() calls this, so no subclass
// needs to know the payslip format β that knowledge lives here.
public abstract double computeMonthlyPay();
public String generatePayslip() {
return String.format("Payslip[employee=%s, pay=%.2f]", name, computeMonthlyPay());
}
// Default: regular employees have no approval authority.
// Manager overrides this; all other subtypes inherit the default.
public boolean canApprove(double amount) { return false; }
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
}How It Works
Department.getTotalMonthlyPayroll() iterates a polymorphic list. Each subtype computes its own pay; the loop knows nothing about the calculation details.
Departmentholds aList<Employee>containingManager,Engineer, andContractorat runtime.- Each
computeMonthlyPay()call dispatches to the correct override via virtual dispatch. Departmentnever usesinstanceof. If it did, that would signal the hierarchy needs rethinking.- Adding a new subtype (
Intern,PartTime) requires zero changes toDepartmentor any caller.
Common Pitfalls
| Anti-Pattern | What Goes Wrong | Fix |
|---|---|---|
| Inheriting for code reuse only | Child picks up parent methods that don't belong to it (Stack.add(index, E) bypasses LIFO). | Composition: use a field of the parent type, delegate only the methods you need. |
| Violating LSP | Subtype changes behavior the caller depends on (Square.setWidth also changes height). | Fail the IS-A test: if the child cannot honor parent contracts, don't inherit. |
| Calling overridable methods from constructor | Subclass override fires before subclass fields are initialized. Null pointer or wrong state. | Only call final or private methods from constructors. |
| Deep hierarchies (4+ levels) | Changes ripple everywhere. Understanding any class requires reading its entire ancestry. | Flatten to 2 levels max. Use interfaces and composition for cross-cutting behavior. |
| Fragile base class | Adding a public method to the base silently breaks a subclass that had the same method name. | Only inherit from classes designed and documented for inheritance. |
Calling overridable methods from constructors
When new Manager(...) runs, Java executes Employee() first. If Employee() calls a method that Manager overrides, Java dispatches to Manager's version via virtual dispatch -- before Manager's own fields are initialized. The result is a NullPointerException or silently wrong state. Only call final or private methods from constructors.
Inheritance or Composition?
| Question | Inheritance | Composition |
|---|---|---|
| Relationship type | IS-A (child is a specialization) | HAS-A (child uses a collaborator) |
| Coupling level | Tight (child coupled to parent internals) | Loose (child uses interface only) |
| Runtime flexibility | Fixed at compile time | Can swap implementation at runtime |
| Polymorphism | Automatic via virtual dispatch | Yes, via interface type |
| Effective Java | Item 18: avoid when in doubt | Prefer by default |
Real-World Examples
AbstractList and ArrayList in the JDK are textbook correct inheritance. ArrayList IS-A AbstractList IS-A AbstractCollection IS-A Collection. Every operation valid on Collection works on ArrayList. The hierarchy is 3 levels, designed for extension, with AbstractList providing skeletal implementations for subclassers.
HttpServlet (Jakarta EE) uses inheritance correctly: your MyServlet IS-A HttpServlet. Override doGet and doPost. The parent handles HTTP protocol bookkeeping; you provide domain logic. This is the Template Method pattern built on inheritance.
java.util.Stack extends java.util.Vector is the canonical counterexample. Stack IS-NOT-A Vector: stacks are LIFO structures, not indexed lists. stack.add(0, element) inserts at the bottom, bypassing LIFO semantics. The correct model is composition: Deque<E> stack = new ArrayDeque<>().
Spring's ApplicationEvent hierarchy works differently: ApplicationListener<E> is a functional interface. You implement it rather than extending a base listener class. The same listener can handle unrelated event types without being forced into a hierarchy.
Interview Tips
Mistake 1: "I used inheritance here to share the id and name fields."
What to say: "Sharing fields is not a valid reason to inherit. Inheritance creates an IS-A contract the entire type system must honor. For shared state without IS-A, use composition or a shared value object."
Mistake 2: Not knowing the java.util.Stack extends java.util.Vector design error.
What to say: "Java's Stack class is a known mistake. It inherits from Vector, so you can call stack.add(0, element) which inserts at the bottom, breaking LIFO. The correct approach uses composition: Deque<E> stack = new ArrayDeque<>()."
Mistake 3: Calling an overridable method from a constructor.
public class Parent {
public Parent() {
init(); // dangerous: if Child overrides init() and uses a Child field,
// that field is null when Parent() runs.
}
protected void init() {}
}
What to say: "Only call final or private methods from constructors. Virtual dispatch is live before the subclass constructor runs, so an overridden method can access uninitialized subclass state."
Mistake 4: Confusing overriding with overloading.
- Overriding: same name, same signature, different class in hierarchy. Runtime polymorphism.
- Overloading: same name, different parameters, same or different class. Compile-time resolution.
Always use @Override. If the annotation triggers a compile error, you were overloading, not overriding.
Signal answer
"Effective Java Item 18 says prefer composition over inheritance. I default to composition and only choose inheritance when three conditions hold: it is a genuine IS-A, the base class was designed for extension, and I need runtime polymorphism."
Test Your Understanding
Quick Recap
- The correct test for inheritance is substitutability (LSP), not code sharing. If you cannot pass the subtype wherever the parent is expected, don't inherit.
- IS-A means every operation valid on the parent is also valid on the child. HAS-A means "contains a reference to." Use composition for HAS-A.
java.util.Stack extends java.util.Vectoris a known design error in the JDK. Know it and cite it.- Only inherit from classes designed and documented for inheritance. Inheriting from arbitrary library classes creates fragile base class problems.
- Never call overridable methods from a constructor. Virtual dispatch fires before the subclass constructor runs.
- Multiple inheritance of behavior is achieved through Java interfaces with default methods. The diamond problem is resolved by requiring manual disambiguation.
- In interviews: "prefer composition over inheritance" (Effective Java Item 18) is a signal answer. Follow it with the three conditions where inheritance IS appropriate.