Inheritance vs Composition in Java: A Practical Guide
-
Last Updated: September 17, 2025
-
By: javahandson
-
Series
Inheritance vs composition in Java explained with examples: what extends really exposes, final and sealed classes, and when to delegate instead.
Inheritance vs composition in Java is one of the oldest design questions in object-oriented code. Learn what extends really hands a subclass, how final and sealed lock a class down, and when a plain field beats extends.
Inheritance vs composition in Java comes down to a single question. Do you want your class to be another thing, or to use another thing?
Both approaches reuse code. Inheritance does it with the extends keyword. Composition does it with a plain field holding another object.
Picture a car. A Car IS-A Vehicle, so inheritance fits there. A Car HAS-A Engine, so composition fits that one. Swap those two around and the design starts to feel wrong immediately.
Most beginners reach for extends far too often. It looks like the OOP thing to do, and it saves typing on day one. Six months later the parent class changes and four subclasses break at once.
So this article goes deeper than the slogan. We will look at what a subclass really gets. We will see how much of your class you give away the moment you allow extends. Then we will shut that door with final or sealed. Only then does the choice get easy.
Here is the plan:
You should know the basics of extends before starting. If any of that feels shaky, read our guide to inheritance in Java first.
Inheritance lets a child class acquire the visible fields and methods of a parent. One keyword sets it up, and the child immediately behaves like the parent.
package com.javahandson;
class Student {
String name;
void study() {
System.out.println("Student studies");
}
}
class SchoolStudent extends Student {
void getLunchBox() {
System.out.println("School student gets the lunch box");
}
}SchoolStudent IS-A Student. It picks up study() and name without writing either one.
The relationship runs deeper than code reuse, though. Any method expecting a Student now accepts a SchoolStudent as well. That substitutability is the real prize.
Composition builds a bigger type out of smaller ones. Instead of extending, your class holds an instance and forwards work to it.
package com.javahandson;
class Engine {
void start() {
System.out.println("Engine started");
}
}
class Car {
private final Engine engine = new Engine(); // HAS-A relationship
void drive() {
engine.start(); // delegation
System.out.println("Car is moving");
}
}
public class Main {
public static void main(String[] args) {
new Car().drive();
}
}
// Output:
// Engine started
// Car is movingCar HAS-A Engine. It calls into Engine, yet no Car is ever an Engine.
Notice that engine field. Marking it private means nothing outside Car can touch the engine directly. You decide exactly which parts of Engine leak out through drive().
Say you need a list that counts every item ever added. Both approaches solve it.
// Approach 1: inheritance
class CountingListA<E> extends java.util.ArrayList<E> {
private int addCount = 0;
@Override
public boolean add(E element) {
addCount++;
return super.add(element);
}
}
// Approach 2: composition
class CountingListB<E> {
private final java.util.List<E> items = new java.util.ArrayList<>();
private int addCount = 0;
public boolean add(E element) {
addCount++;
return items.add(element);
}
public int size() {
return items.size();
}
}Version A looks shorter and smarter. Hold that thought, because section 6.2 shows exactly how it breaks.
Before choosing, you need to know the size of the gift. Access modifiers decide which members cross the boundary into a subclass.
A subclass never sees a private member. Try it and the compiler stops you cold.
package com.javahandson;
class Student {
private String name = "Shweta";
public String getName() {
return name; // fine, same class
}
}
class CollegeStudent extends Student {
void printName() {
System.out.println(name); // error: name has private access
}
}
// Output:
// java: name has private access in com.javahandson.StudentHere is the subtlety that trips people up. That field still sits inside every CollegeStudent object in memory. The child code simply has no way to name it.
package com.javahandson;
class Student {
private String name = "Shweta";
public String getName() {
return name;
}
}
class CollegeStudent extends Student {
void printName() {
System.out.println("Sub class : " + getName()); // reached through a public method
}
}
public class Main {
public static void main(String[] args) {
CollegeStudent collegeStudent = new CollegeStudent();
System.out.println("Super class : " + collegeStudent.getName());
collegeStudent.printName();
}
}
// Output:
// Super class : Shweta
// Sub class : ShwetaRoute the access through a public getter and everything works. Private hides the field from the child, not from the object.
Protected exists almost entirely for inheritance. Subclasses reach the member directly, even from a completely different package.
package com.javahandson;
public class Student {
protected String name = "Shweta";
public String getName() {
return name;
}
}
// ---- different package ----
package com.diff.pkg;
import com.javahandson.Student;
public class CollegeStudent extends Student {
public void printName() {
System.out.println("Different pkg, direct field access : " + name);
System.out.println("Different pkg, public method : " + getName());
}
}
// Output:
// Different pkg, direct field access : Shweta
// Different pkg, public method : ShwetaOne rule surprises almost everyone. Across packages, a subclass may touch a protected member only through a reference of its own type. Holding a plain Student reference and reading student.name from com.diff.pkg still fails to compile.
Treat protected as a promise. Every protected member becomes part of the contract you owe to future subclasses, and you cannot quietly remove it later.
Public members travel everywhere. Packages, subclasses, and unrelated classes all see them.
package com.javahandson;
public class Student {
public String name = "Shweta";
public String getName() {
return name;
}
}
// ---- different package ----
package com.diff.pkg;
import com.javahandson.Student;
public class CollegeStudent extends Student {
public void printName() {
System.out.println("Different pkg, public field : " + name);
System.out.println("Different pkg, public method : " + getName());
}
}
// Output:
// Different pkg, public field : Shweta
// Different pkg, public method : ShwetaPublic fields carry a cost, though. Once code outside your class reads name directly, you can never change how that value gets stored.
Write no modifier at all and the member gets default access, also known as package-private. Subclasses in the same package see it fine.
Move that subclass to another package, however, and the member vanishes completely.
package com.javahandson;
public class Student {
String name = "Shweta"; // package-private
String getName() {
return name;
}
}
// ---- different package ----
package com.diff.pkg;
import com.javahandson.Student;
public class CollegeStudent extends Student {
public void printName() {
System.out.println(name); // error
System.out.println(getName()); // error
}
}
// Output:
// java: name is not public in com.javahandson.Student; cannot be accessed from outside package
// java: cannot find symbol
// symbol: method getName()
// location: class com.diff.pkg.CollegeStudentRead that second error carefully. The compiler says “cannot find symbol”, not “access denied”. From another package, a package-private method might as well not exist.
| Modifier | Same Class | Same Package | Subclass, Other Package | Anywhere Else |
|---|---|---|---|---|
| private | Yes | No | No | No |
| default | Yes | Yes | No | No |
| protected | Yes | Yes | Yes, via its own type | No |
| public | Yes | Yes | Yes | Yes |
Look at that middle column and the design lesson jumps out. Everything except private forms an API for your subclasses. Composition exposes nothing at all, because your field stays private and you write the forwarding methods yourself.
For a fuller treatment of the four levels, see our guide to access specifiers in Java.
Write a class with no extends clause and Java adds one for you. The parent is java.lang.Object, the root of every hierarchy.
class Student {
// no extends keyword written
}
// Java compiles it as if you had written:
class Student extends Object {
// ...
}So you already use inheritance in every program, whether you meant to or not. Even a composition-only design sits on top of Object.
Object hands every class a small set of methods:
| Method | Modifier | What It Does |
|---|---|---|
| toString() | public | Returns a text form of the object |
| equals(Object) | public | Compares two objects for equality |
| hashCode() | public | Returns the object’s hash code |
| getClass() | public final | Returns the runtime Class object |
| clone() | protected | Creates and returns a copy of the object |
| wait() | public final | Pauses the thread until another calls notify |
| notify() | public final | Wakes one thread waiting on this monitor |
| notifyAll() | public final | Wakes every thread waiting on this monitor |
Two details deserve a note. First, getClass() is final, so no class ever changes it. It hands back a Class object, not a String. Plenty of older tutorials get that one wrong. Second, those tutorials also list finalize(). Java 18 marked it for removal, so treat it as dead weight.
package com.javahandson;
class Student {
String name = "Shweta";
}
public class Main {
public static void main(String[] args) {
Student student = new Student();
System.out.println("toString : " + student.toString());
System.out.println("equals : " + student.equals(student));
System.out.println("hashCode : " + student.hashCode());
System.out.println("getClass : " + student.getClass());
}
}
// Output:
// toString : com.javahandson.Student@6acbcfc0
// equals : true
// hashCode : 1791741888
// getClass : class com.javahandson.StudentComposition changes what these methods mean. Wrap an ArrayList in your own class and toString() prints your wrapper, not the list inside it.
That is a feature, not a bug. You control the whole surface. With inheritance you would have picked up ArrayList’s toString whether you wanted it or not.
The trade-off cuts both ways, of course. Composition means writing equals, hashCode, and toString yourself when you need sensible ones. Our guide to the Object class in Java covers how to write them properly.
Allowing extends is a decision, not a default. Java gives you three ways to control it.
Mark a class final and the door shuts. No subclass may exist.
package com.javahandson;
final class Student {
String name = "Shweta";
}
class CollegeStudent extends Student {
}
// Output:
// java: cannot inherit from final com.javahandson.StudentString works exactly this way, and for good reason. A subclass could break immutability, which would wreck security checks and string pooling across the entire platform.
Use final whenever your class was never designed for extension. Documenting a class for subclassing takes real effort, so do not pay that cost by accident.
Sometimes you want subclasses but not on one specific method. Mark that method final.
package com.javahandson;
class Student {
String name = "Shweta";
final void study() {
System.out.println(name + " studies");
}
}
class CollegeStudent extends Student {
void study() { // not allowed
System.out.println("Suraj studies");
}
}
// Output:
// java: study() in com.javahandson.CollegeStudent cannot override study() in com.javahandson.Student
// overridden method is finalRemove the final keyword and the override compiles happily, printing “Suraj studies” through a Student reference. The keyword is the only thing standing between the two behaviours.
Final methods pair well with the template method pattern. Fix the overall algorithm in a final method, and let subclasses fill in the individual steps.
On a field, final does something else entirely. It blocks reassignment rather than inheritance.
package com.javahandson;
class Student {
final String name = "Shweta";
void study() {
System.out.println(name + " studies");
}
}
class CollegeStudent extends Student {
void study() {
name = "Suraj"; // error: name is final
System.out.println(name + " studies");
}
}
// Output:
// java: cannot assign a value to final variable nameDrop that assignment and the subclass reads name perfectly well. Final fields cross into subclasses like any other field, they just refuse to change.
Watch out for one classic misunderstanding. Final locks the reference, not the object behind it. A final List still accepts new elements all day long.
Our dedicated guide to the final keyword in Java digs into all three uses.
Java 17 added a third option. A sealed class names the exact classes allowed to extend it.
package com.javahandson;
sealed class Student permits SchoolStudent, CollegeStudent {
String name;
}
final class SchoolStudent extends Student {
}
non-sealed class CollegeStudent extends Student {
}
// Any other class trying to extend Student fails to compile
Every permitted subclass must pick one of three labels. Final closes the branch, sealed narrows it further, and non-sealed reopens it to anyone.
This lands between the extremes nicely. You keep a real hierarchy, yet nobody outside your design can slip a new type into it.
| Aspect | Inheritance | Composition |
|---|---|---|
| Relationship | IS-A | HAS-A |
| Set up with | The extends keyword | A field of the other type |
| Coupling | Tight, down to internals | Loose, only the public API |
| Decided at | Compile time, permanently | Runtime, swap any time |
| How much you reuse | Every visible member | Only what you delegate |
| Substitutable for the other type | Yes | No, unless you add an interface |
| How many sources | One superclass only | As many fields as you like |
| Testing | Parent behaviour comes along | Inject a fake and isolate |
One row explains most real bugs. Tight coupling means your subclass depends on choices the parent never promised to keep.
Remember CountingListA from section 2.3? Time to break it.
package com.javahandson;
import java.util.ArrayList;
import java.util.List;
class CountingList<E> extends ArrayList<E> {
private int addCount = 0;
@Override
public boolean add(E element) {
addCount++;
return super.add(element);
}
public int getAddCount() {
return addCount;
}
}
public class Main {
public static void main(String[] args) {
CountingList<String> names = new CountingList<>();
names.addAll(List.of("Suraj", "Shweta", "Ravi"));
System.out.println("Items : " + names.size());
System.out.println("Counted: " + names.getAddCount());
}
}
// Output:
// Items : 3
// Counted: 3Three items, count of three. Looks correct, right?
It is correct by accident. ArrayList.addAll happens to call add for each element, so our override fires three times. Nothing in the documentation promises that behaviour.
Now imagine a future release optimising addAll to copy the array in one shot. Our count silently drops to zero, and no compiler warning appears anywhere.
The composition version never has this problem. It counts inside its own add method and forwards to a private list, so what ArrayList does internally stops mattering.
Inheritance fixes the parent at compile time. Composition lets you change the collaborator whenever you like.
package com.javahandson;
interface PaymentMethod {
void pay(double amount);
}
class UpiPayment implements PaymentMethod {
public void pay(double amount) {
System.out.println("Paid " + amount + " via UPI");
}
}
class CardPayment implements PaymentMethod {
public void pay(double amount) {
System.out.println("Paid " + amount + " via card");
}
}
class Checkout {
private PaymentMethod method; // HAS-A, and replaceable
Checkout(PaymentMethod method) {
this.method = method;
}
void switchTo(PaymentMethod newMethod) {
this.method = newMethod;
}
void buy(double amount) {
method.pay(amount);
}
}
public class Main {
public static void main(String[] args) {
Checkout checkout = new Checkout(new UpiPayment());
checkout.buy(499.0);
checkout.switchTo(new CardPayment());
checkout.buy(1299.0);
}
}
// Output:
// Paid 499.0 via UPI
// Paid 1299.0 via cardOne Checkout object paid two different ways. No subclass of Checkout exists, and none is needed.
Try that with inheritance and you would need UpiCheckout and CardCheckout, plus a new object every time the user changes their mind.
Extend a class when all of these hold true:
Fail any one of those and composition is probably the better answer.
These pass the test without argument:
Exceptions make the point beautifully. Catching Exception catches every subclass, and that only works because of inheritance.
These do not pass:
Reach for a field instead of extends when you notice any of these:
That second sign is the loudest of all. Blocking inherited methods proves the IS-A relationship was false from the start.
Composition is not free, and honest advice says so.
An interface usually fixes the middle point. Implement the same interface your field implements, and substitutability comes back without the coupling.
Here is a report generator built the tempting way. Somebody needed formatting helpers, so they extended the class holding them.
package com.javahandson;
class Formatter {
String bold(String text) {
return "**" + text + "**";
}
String upper(String text) {
return text.toUpperCase();
}
void debugDump() {
System.out.println("Formatter internals dumped");
}
}
// SalesReport IS-A Formatter? That sentence is nonsense.
class SalesReport extends Formatter {
void print(String region, double total) {
System.out.println(bold(upper(region)) + " : " + total);
}
}
public class Main {
public static void main(String[] args) {
SalesReport report = new SalesReport();
report.print("west zone", 84000);
report.debugDump(); // leaked into the report's public API
}
}
// Output:
// **WEST ZONE** : 84000.0
// Formatter internals dumpedSpot the leak on that last line. Every caller of SalesReport can now invoke debugDump, because inheritance published the whole Formatter API.
Now the same code with a field instead of a parent.
package com.javahandson;
class Formatter {
String bold(String text) {
return "**" + text + "**";
}
String upper(String text) {
return text.toUpperCase();
}
void debugDump() {
System.out.println("Formatter internals dumped");
}
}
class SalesReport {
private final Formatter formatter = new Formatter(); // HAS-A
void print(String region, double total) {
System.out.println(formatter.bold(formatter.upper(region)) + " : " + total);
}
}
public class Main {
public static void main(String[] args) {
SalesReport report = new SalesReport();
report.print("west zone", 84000);
// report.debugDump(); // no such method now, exactly as intended
}
}
// Output:
// **WEST ZONE** : 84000.0Three things improved, and only one line of real code moved.
The recipe generalises nicely. Turn the parent into a private field, keep the methods you actually call, and delete the rest of the surface.
This is the number one abuse of inheritance. Somebody wants one utility method, so they extend the class that has it.
Java’s own library shipped this bug. Stack extends Vector, which means every Java Stack lets you insert an element into the middle. No stack should ever allow that.
Protected feels like a safe middle setting. It is not, because a protected field becomes a permanent part of your subclass contract.
Prefer a protected getter over a protected field. You keep the freedom to change how the value gets computed or stored later.
Extending a class publishes every public and protected member through your class too. Section 9.1 showed debugDump escaping exactly this way.
Ask yourself a blunt question before typing extends. Would you be happy documenting every inherited method as part of your own API?
Five levels of extends look impressively organised on a diagram. Debugging one is miserable, because the method you want could live at any level.
Keep hierarchies two or three levels deep. Anything beyond that usually signals composition trying to get out.
“Favour composition over inheritance” is good advice, not a law. People quote it and then wrap things that genuinely should extend.
Frameworks, exception hierarchies, and abstract template classes all depend on real inheritance. When the IS-A sentence rings true and you need substitutability, extends is simply the right tool.
A: Inheritance models an IS-A link with extends. The child becomes a kind of the parent and can stand in for it. Composition models a HAS-A link with a field. One class holds another and passes work to it. The first ties the two classes together tightly. The second shows only the methods you choose to pass on.
A: A subclass leans on how the parent works inside. The parent never promised to keep any of that stable. So one edit upstream can break every subclass at once. People call this the fragile base class problem. Composition talks to the other object through its public API only, so those edits rarely reach you.
A: It describes a subclass that breaks when its parent changes, even though the subclass code stayed the same. The classic example counts elements by overriding add, which works only because addAll happens to call add internally. Change that implementation detail upstream and the count silently goes wrong, with no compiler error.
A: No, not directly. The private field still occupies memory inside the child object, but the child code has no way to name it. Reach the value through a public or protected accessor instead, or mark the member protected if subclasses genuinely need it.
A: A subclass in a different package can reach a protected member. There is a catch, though. It must go through a reference of its own type or a subtype. Hold a plain superclass reference in that other package and the read fails to compile. Inside one package, protected acts just like default access.
A: Mark the class final and no subclass may exist, which is how String protects its immutability. Java 17 added sealed classes for finer control, letting you permit an explicit list of subclasses. Making every constructor private blocks subclassing as well, since no subclass could call super().
A: On a class, final blocks all subclasses. Used on a method, it blocks overriding but still allows subclasses. A final field is a different job. It has nothing to do with extends. It just stops you from setting a new value. Child classes still get the field and read it as normal.
A: Java gives every class one common root. That way any value fits in a variable of type Object, and a few methods always exist. Lists, printing, and equality checks all lean on that promise. Leave out the extends clause and the compiler adds extends Object for you.
A: A sealed class, added in Java 17, lists the exact classes permitted to extend it. Each permitted subclass must then declare itself final, sealed, or non-sealed. It sits between an open class and a final one, giving you a real hierarchy that outsiders cannot extend.
A: Yes. You often write forwarding methods by hand, which adds boilerplate for a wide API. Your wrapper also loses substitutability, so code expecting the wrapped type will not accept it. Implementing the same interface as the wrapped object usually solves that second problem.
Let us wrap up what we covered. Inheritance vs composition in Java is really a question about relationships, not about saving keystrokes.
Extending a class publishes every public and protected member through your own type. Private members stay hidden, protected members become a promise, and package-private members disappear the moment a subclass moves packages.
Every class already extends Object, so a handful of methods always exist. Use final to close a class or a method, and sealed to permit a named list of subclasses.
Composition hands you a private field and full control of your API. It survives upstream changes, allows swapping collaborators at runtime, and makes testing far easier.
Here is the test worth remembering. Say the IS-A sentence out loud, and check whether callers really need to substitute one type for the other. Both true means extends, anything else means a field.