Inheritance vs composition: is-a vs has-a
The is-a test and Liskov substitution, why extending a JDK collection silently double counts, the forwarding wrapper that fixes it, when inheritance is still right, and how this decides LLD interviews.
On this page
🧩 What is inheritance vs composition?
Both are ways for one class to reuse another. Inheritance says "my class is a kind of that class" (class SavingsAccount extends Account). Composition says "my class has a reference to that class and calls it" (class Checkout { private final PaymentGateway gateway; }). For example, a Car is a Vehicle (inheritance fits), but a Car has an Engine (composition fits): nobody would hand a car to code that expects an engine.
This is one of the oldest design questions in object-oriented programming, and interviewers still love it. It sounds like a vocabulary question, yet a good answer needs you to explain why inheritance breaks encapsulation, show it with code, and know when inheritance is still the right call. In low-level design (LLD) rounds, it quietly decides whether your class diagram survives the first "what if we add..." follow-up.
What you’ll learn
- Tell is-a from has-a with the Liskov substitution test, not only the sentence test
- Explain how inherited self-use (
AbstractCollection.addAllcallingadd) makes aHashSetsubclass count 6 for 3 items - Spot the opposite failure: parent methods like
putAllandmergethat bypass your overrides - Write a forwarding wrapper (decorator) that works with any
Setimplementation - Name when inheritance is the right tool: true subtypes, designed for extension, same owner,
sealedhierarchies - Use composition to avoid class explosions in LLD designs (strategy, decorator)
For this lesson, we'll focus on the is-a test and the Liskov substitution principle, a concrete case where extending a JDK collection silently gives wrong answers, the forwarding wrapper that fixes it, when inheritance is genuinely the better tool, and how all of this shows up in LLD interviews.
Below the line (out of scope)
- The mechanics of
extends, constructor chaining andsuper: covered in A guide to inheritance in Java and The super keyword - Association, aggregation and UML composition: covered in Association, aggregation and composition
- Full design-pattern catalogues (we touch decorator and strategy only as examples)
The 30-second answer
Inheritance models is-a and composition models has-a. Use inheritance only when the subclass is a true subtype, meaning it can stand in anywhere the parent is used without surprising callers (Liskov substitution), and when the parent was designed for extension. Otherwise, prefer composition: hold the other object in a private final field and delegate to it. Inheritance couples you to the parent's implementation, including which of its methods call which, so a subclass of HashSet that counts additions can report 6 after adding 3 items. Composition depends only on the public contract, lets you swap implementations at runtime, and avoids class explosions.
Interview radar
Asked very often: Expect the interviewer to ask when inheritance is safe, when composition is safer, and how Liskov substitution changes the is-a test. Senior rounds probe: Strong rounds dig into HashSet self-use, parent methods that bypass overrides, and why a forwarding wrapper is safer across JDK upgrades. Production angle: This shows up when a subclass of a vendor client or collection misses new methods and silently skips retries, validation, metrics or authorization.
01Picture it first: moving into a house vs hiring a service
Think of inheritance as moving into someone's house. You get every room, the plumbing and the wiring for free, but you also live with every decision the owner made, and when they renovate the kitchen, your breakfast routine breaks. Composition is hiring a service. You call the plumber when you need plumbing; you don't care how they run their business, and if they disappoint you, you hire someone else.
The diagram below shows the same reuse done both ways. On the left, the counting code and the HashSet code live inside one object, so a call from HashSet's own code can land back in yours. On the right, the wrapper and the real set are two objects, and the set's internal calls never leave it.
Left: one TrackedSkuSet object that contains both the subclass code and the inherited HashSet code, with an internal self-call looping back into the override. Right: a CountingSet wrapper object holding a reference to a separate HashSet object, where internal calls stay inside the HashSet
Notice two things. With inheritance there is no wall between your code and the parent's code: this is the same object for both. With composition the wall is the parent's public interface, and that wall is exactly what encapsulation is supposed to give you.
The is-a test and Liskov substitution
The quick test is the sentence "every X is a Y". Every SavingsAccount is an Account: plausible. Every Stack is a Vector: no, a stack shouldn't let you insert at index 3. The JDK got that one wrong in Java 1.0, and java.util.Stack still exposes insertElementAt today. Properties extends Hashtable<Object, Object> is the same mistake: you can put a non-String value and then getProperty returns null for it.
The sentence test is necessary but not sufficient. The precise version is the Liskov substitution principle (LSP): if code works with a Y, it must keep working, with the same promises, when you hand it an X. A subclass may accept more and promise more, but it must never demand more or deliver less.
class Account {
protected BigDecimal balance = BigDecimal.ZERO;
void deposit(BigDecimal amount) { balance = balance.add(amount); }
void withdraw(BigDecimal amount) { balance = balance.subtract(amount); }
}
// "A fixed deposit IS an account", says the sentence test...
class FixedDepositAccount extends Account {
@Override
void withdraw(BigDecimal amount) {
throw new UnsupportedOperationException("locked until maturity");
}
}
void payRent(Account from) {
from.withdraw(new BigDecimal("25000")); // blows up for a FixedDepositAccount
}
The sentence test passed, but the behaviour test failed. payRent was written against Account's promise that withdraw works, and the subclass took that promise away. That's the signal to model it differently: perhaps a WithdrawableAccount interface that FixedDepositAccount doesn't implement.
02Under the hood: why extending HashSet breaks
Try it yourself first
Before reading on: we want a set of product SKUs that also counts how many add attempts it has seen. Predict what skus.added() prints.
class TrackedSkuSet extends HashSet<String> {
private int added = 0;
@Override
public boolean add(String sku) {
added++;
return super.add(sku);
}
@Override
public boolean addAll(Collection<? extends String> skus) {
added += skus.size();
return super.addAll(skus);
}
int added() { return added; }
}
var skus = new TrackedSkuSet();
skus.addAll(List.of("SKU-1", "SKU-2", "SKU-3"));
System.out.println(skus.added()); // prints: 6
Three items, count of six. Nothing in the code we wrote looks wrong, and that's what makes it dangerous.
Here's what happens. HashSet doesn't define its own addAll; it inherits AbstractCollection.addAll, which loops over the argument and calls add(e) for each element. That call is compiled as invokevirtual add on this. At runtime the JVM looks up add in the vtable of the actual object's class, which is TrackedSkuSet, so every element goes through our override and increments the counter a second time.
The diagram walks through the four steps.
A four-step flow: the caller invokes addAll, the override adds 3, super.addAll runs the inherited AbstractCollection loop, which calls this.add three times, and virtual dispatch sends each call back into the overriding add, which adds 1 each time for a total of 6
Notice three things:
super.addAll(...)compiles toinvokespecial, which pins the call to the parent's version. But the parent's code then makes ordinaryinvokevirtualcalls, and those dispatch to your overrides. This is called self-use, and it's an implementation detail.- Whether
HashSet.addAllcallsaddis a detail of today'sAbstractCollection, not a promise of theSetcontract. A different parent, or a future optimisation, may not do it. - "Fixing" it by deleting our
addAlloverride gives 3 today, but now our correctness depends on that self-use staying in place forever.
The opposite failure: the parent stops calling you
Self-use can also bite the other way. Say a pricing team validates prices in a HashMap subclass:
class PriceBook extends HashMap<String, BigDecimal> {
@Override
public BigDecimal put(String sku, BigDecimal price) {
if (price.signum() < 0) throw new IllegalArgumentException("negative price for " + sku);
return super.put(sku, price);
}
}
var book = new PriceBook();
book.putAll(Map.of("SKU-9", new BigDecimal("-5.00")));
System.out.println(book); // prints: {SKU-9=-5.00}
book.merge("SKU-8", new BigDecimal("-1"), BigDecimal::add);
System.out.println(book.get("SKU-8")); // prints: -1
HashMap.putAll and merge go straight to internal helpers and never call put. The invariant "no negative prices" is now enforced on one door out of many (put, putAll, putIfAbsent, merge, compute, replace and more). And when the parent class gains a new mutator in a later JDK, your subclass silently gains a new unguarded door. Java 8 did exactly that to every Map implementation when it added merge, compute and friends.
Many candidates say "inheritance is bad because it's tightly coupled" and stop there. That's a slogan, not an answer. The specific mechanism is that a subclass depends on the parent's self-use patterns, which aren't part of the public contract, and the parent can add methods that bypass the subclass's invariants. Show either example and you've answered the question.
03The fix: a forwarding wrapper
Composition fixes both problems with one idea: don't be the set, hold a set, and implement the Set interface by forwarding each call to it. Joshua Bloch calls the reusable part a forwarding class. Here's my version:
class ForwardingSet<E> implements Set<E> {
private final Set<E> delegate;
ForwardingSet(Set<E> delegate) { this.delegate = Objects.requireNonNull(delegate); }
public int size() { return delegate.size(); }
public boolean isEmpty() { return delegate.isEmpty(); }
public boolean contains(Object o) { return delegate.contains(o); }
public Iterator<E> iterator() { return delegate.iterator(); }
public Object[] toArray() { return delegate.toArray(); }
public <T> T[] toArray(T[] a) { return delegate.toArray(a); }
public boolean add(E e) { return delegate.add(e); }
public boolean remove(Object o) { return delegate.remove(o); }
public boolean containsAll(Collection<?> c) { return delegate.containsAll(c); }
public boolean addAll(Collection<? extends E> c) { return delegate.addAll(c); }
public boolean retainAll(Collection<?> c) { return delegate.retainAll(c); }
public boolean removeAll(Collection<?> c) { return delegate.removeAll(c); }
public void clear() { delegate.clear(); }
@Override public boolean equals(Object o) { return delegate.equals(o); }
@Override public int hashCode() { return delegate.hashCode(); }
@Override public String toString() { return delegate.toString(); }
}
class CountingSet<E> extends ForwardingSet<E> {
private int added = 0;
CountingSet(Set<E> delegate) { super(delegate); }
@Override public boolean add(E e) { added++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
int added() { return added; }
}
var skus = new CountingSet<String>(new HashSet<>());
skus.addAll(List.of("SKU-1", "SKU-2", "SKU-3"));
System.out.println(skus.added()); // prints: 3
var sorted = new CountingSet<String>(new TreeSet<>()); // same wrapper, any Set
Notice what changed:
- When
HashSet.addAllloops and callsthis.add,thisis the innerHashSet, not our wrapper. Its self-use stays inside it. - The wrapper works with any
Set:HashSet,TreeSet,ConcurrentHashMap.newKeySet(). Inheritance would need one subclass per implementation. CountingSet extends ForwardingSetis still inheritance, and that's fine: we own both classes andForwardingSetexists to be extended. This is the decorator pattern.
The cost is the boilerplate. Guava ships ForwardingSet, ForwardingMap and friends so you don't have to write them; for your own interfaces, IDEs generate delegate methods in two clicks.
Wrappers have one known blind spot, sometimes called the SELF problem: if the wrapped object passes this to someone else (registering itself as a listener, say), callbacks go to the inner object and bypass the wrapper. It rarely matters for collections, but it does for callback-heavy frameworks.
04Real world: where this decision shows up
A pattern I've seen in more than one codebase: a RetryingHttpClient extends SomeVendorHttpClient overrides execute() to add retries. A minor upgrade of the vendor library adds executeAsync(), which calls an internal method instead of execute(). Every async call in the service silently loses its retries, and nobody notices until an outage turns a transient 503 into failed payments. Wrapping the client behind your own HttpClient interface would have made the new method a compile error in your code instead of a silent gap.
The other common production smell is the class explosion. Suppose notifications vary by channel (email, SMS, push) and by behaviour (plain, retrying, rate-limited, retrying and rate-limited). Inheritance needs one class per combination: RetryingRateLimitedSmsNotifier is 1 of 3 × 4 = 12 classes. Composition needs 3 channels plus 2 decorators, combined at runtime:
interface Notifier { void send(String userId, String message); }
final class SmsNotifier implements Notifier {
public void send(String userId, String message) {
System.out.println("SMS to " + userId + ": " + message);
}
}
final class RetryingNotifier implements Notifier {
private final Notifier inner;
private final int attempts;
RetryingNotifier(Notifier inner, int attempts) { this.inner = inner; this.attempts = attempts; }
public void send(String userId, String message) {
for (int i = 1; ; i++) {
try { inner.send(userId, message); return; }
catch (RuntimeException e) { if (i == attempts) throw e; }
}
}
}
Notifier otp = new RetryingNotifier(new SmsNotifier(), 3);
otp.send("u-42", "Your OTP is 481920"); // prints: SMS to u-42: Your OTP is 481920
Adding a fourth channel is one class, not four. And because Checkout depends on the Notifier interface, tests can pass a fake.
05Advanced: when inheritance is the right tool
Composition is the default, not a religion. Inheritance is the better choice when all of these hold:
- True subtype. The subclass passes the LSP behaviour test, not only the sentence test.
- Designed for extension. The parent documents its self-use (the JDK uses
@implSpecfor this), keeps hooksprotected, and doesn't call overridable methods from its constructor. - Same owner. Both classes live in the same module or team, so a change to the parent is reviewed with its children in mind.
The decision tree below is the checklist I use before I let extends into an LLD diagram.
A decision tree starts with the is-a question, checks Liskov substitutability, designed-for-extension documentation and same-team ownership, and routes unsafe reuse to composition with a private final delegate
Good examples: AbstractList (you implement get and size, and the docs tell you exactly what the inherited methods call), exception hierarchies (InsufficientFundsException extends PaymentException), the template method pattern where a final method fixes the algorithm and subclasses fill in steps, and sealed hierarchies where the parent lists every permitted subtype (see Sealed classes and interfaces).
If a class is not designed for extension, say so in code: make it final, or give it only private constructors and static factories. That's the other half of Effective Java's advice: design and document for inheritance, or else prohibit it.
Delegation is almost free. A wrapper adds one object, about 16 to 24 bytes with compressed oops (12-byte header, a 4-byte reference, your own fields, padded to 8). Each forwarded call is one extra invokeinterface, and when the call site sees one receiver type, HotSpot's JIT inlines it, so the steady-state cost is usually zero. Don't pick inheritance for speed.
Inheritance (extends) |
Composition (field + delegation) | |
|---|---|---|
| Relationship | is-a | has-a |
| Coupled to | parent's implementation and self-use | the public interface only |
| Chosen | at compile time, fixed | at runtime, swappable |
| Reuse across implementations | one subclass per parent | one wrapper for every implementation |
| Parent adds a method | inherited automatically, may bypass your rules | not exposed until you add it |
| Boilerplate | none | forwarding methods |
| Best for | designed hierarchies, sealed types, template method | decorators, strategies, wrapping third-party code |
One more interview favourite: default methods in interfaces (Java 8) give you inheritance of behaviour without inheritance of state. They're a fine middle ground for small helpers, but they carry the same self-use risk, which is why the JDK documents them with @implSpec. More in Static and default methods in interfaces.
How it shows up in LLD interviews
In a parking-lot or ride-sharing design, the interviewer watches your class diagram for two smells. The first is inheritance for code reuse: class Driver extends User looks fine until a driver also books rides as a rider. A User that has a DriverProfile and a RiderProfile handles that cleanly. The second is behaviour encoded as subclasses: HourlyParkingTicket, DailyParkingTicket, WeekendParkingTicket. A ParkingTicket that has a PricingStrategy lets pricing rules change without touching the ticket hierarchy.
At this point, you should ask your interviewer which parts are likely to change. Whatever varies independently becomes a composed collaborator behind an interface; whatever is a stable, closed set of kinds (vehicle types, say) can be a small sealed hierarchy or an enum.
06How to say it in the interview
When I ask this, I'm listening for three things: the is-a/has-a distinction stated as Liskov substitution, not only the sentence test; a concrete mechanism for why inheritance breaks encapsulation (self-use, or the parent adding methods); and a balanced default, with a clear idea of when inheritance is still right.
In the interview
"Inheritance models is-a, composition models has-a. I default to composition: hold the collaborator in a private final field, depend on its interface and delegate. The reason is that a subclass depends on the parent's implementation, not only its contract. If I extend HashSet and count additions in both add and addAll, I get double counting, because the inherited addAll calls add through virtual dispatch. And if the parent later adds a method, like Map.merge in Java 8, it can bypass my overrides. A forwarding wrapper avoids both and works with any Set. I still use inheritance when the subclass is a true subtype under Liskov, the parent was designed for extension, and we own both, like a template method or a sealed hierarchy. Otherwise I make classes final."
On the whiteboard
inheritance = is-a composition = has-a (field + delegate)
is-a check: LSP -> subtype usable wherever parent is, same promises
why extends breaks:
self-use: AbstractCollection.addAll -> this.add (invokevirtual)
TrackedSkuSet: addAll(3 items) -> count 6
parent adds methods (Map.merge, Java 8) -> bypass overrides
fix: ForwardingSet implements Set { private final Set delegate }
works for HashSet/TreeSet/any; decorator pattern
use extends when: true subtype + designed for extension + same owner
(AbstractList, exceptions, template method, sealed)
else: final class
LLD: varies independently -> strategy/decorator, not subclasses
Further reading (6)
- Effective Java, 3rd ed., Item 18: Favor composition over inheritance; Item 19: Design and document for inheritance or else prohibit it
- JLS §8.4.8 Inheritance, Overriding, and Hiding
- JVMS §6.5 invokevirtual and invokespecial
- AbstractCollection.addAll (Java SE 21 API)
- The Java Tutorials: Inheritance
- Baeldung: Inheritance and Composition (Is-a vs Has-a relationship) in Java