Inheritance vs Composition in Java: A Practical Guide

  • Last Updated: September 17, 2025
  • By: javahandson
  • Series
img

Inheritance vs Composition in Java: A Practical Guide

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.

1. Introduction

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.

1.1 What This Article Covers

Here is the plan:

  • The IS-A and HAS-A relationships, side by side in code
  • What each access modifier gives a subclass, across packages too
  • Why every class already extends Object, and what that hands you
  • Blocking inheritance with final, and narrowing it with sealed
  • The fragile base class problem, with a real example
  • Clear signals for picking one approach over the other
  • A refactor from extends to delegation, step by step
  • Common mistakes and ten interview questions

You should know the basics of extends before starting. If any of that feels shaky, read our guide to inheritance in Java first.

2. Two Ways to Reuse Code

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 moving

Car 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().

2.3 The Same Job Done Two Ways

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.

3. What extends Actually Hands You

Before choosing, you need to know the size of the gift. Access modifiers decide which members cross the boundary into a subclass.

3.1 private: Present but Out of Reach

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.Student

Here 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 : Shweta

Route the access through a public getter and everything works. Private hides the field from the child, not from the object.

3.2 protected: Built for Subclasses

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 : Shweta

One 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.

3.3 public: Open to Everyone

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 : Shweta

Public fields carry a cost, though. Once code outside your class reads name directly, you can never change how that value gets stored.

3.4 Default: Same Package Only

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.CollegeStudent

Read 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.

3.5 The Access Table

ModifierSame ClassSame PackageSubclass, Other PackageAnywhere Else
privateYesNoNoNo
defaultYesYesNoNo
protectedYesYesYes, via its own typeNo
publicYesYesYesYes

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.

4. Every Class Inherits Object

4.1 The Silent extends Object

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.

4.2 The Methods You Get for Free

Object hands every class a small set of methods:

MethodModifierWhat It Does
toString()publicReturns a text form of the object
equals(Object)publicCompares two objects for equality
hashCode()publicReturns the object’s hash code
getClass()public finalReturns the runtime Class object
clone()protectedCreates and returns a copy of the object
wait()public finalPauses the thread until another calls notify
notify()public finalWakes one thread waiting on this monitor
notifyAll()public finalWakes 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.Student

4.3 Why This Matters for the Choice

Composition 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.

5. Locking Inheritance Down

Allowing extends is a decision, not a default. Java gives you three ways to control it.

5.1 final class: Nobody Extends This

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.Student

String 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.

5.2 final method: Nobody Overrides This

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 final

Remove 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.

5.3 final field: A Different Job

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 name

Drop 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.

5.4 sealed classes: The Middle Ground

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.

6. Inheritance vs Composition Head to Head

6.1 The Comparison Table

AspectInheritanceComposition
RelationshipIS-AHAS-A
Set up withThe extends keywordA field of the other type
CouplingTight, down to internalsLoose, only the public API
Decided atCompile time, permanentlyRuntime, swap any time
How much you reuseEvery visible memberOnly what you delegate
Substitutable for the other typeYesNo, unless you add an interface
How many sourcesOne superclass onlyAs many fields as you like
TestingParent behaviour comes alongInject a fake and isolate

One row explains most real bugs. Tight coupling means your subclass depends on choices the parent never promised to keep.

6.2 The Fragile Base Class Problem

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: 3

Three 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.

6.3 Swapping Behaviour at Runtime

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 card

One 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.

7. When Inheritance Is the Right Call

7.1 The Four Green Lights

Extend a class when all of these hold true:

  • The IS-A sentence sounds right. Say it aloud. “A Manager is an Employee” passes, and “A Car is an Engine” fails.
  • Substitution genuinely helps. You want one Employee array holding managers, developers, and interns together.
  • The parent was designed for it. Somebody documented which methods you may override and what each one promises.
  • Behaviour mostly matches. Children share nearly everything and differ in a method or two.

Fail any one of those and composition is probably the better answer.

7.2 Hierarchies That Read Well

These pass the test without argument:

  • Car IS-A Vehicle
  • Teacher IS-A Person
  • SavingsAccount IS-A Account
  • IOException IS-A Exception

Exceptions make the point beautifully. Catching Exception catches every subclass, and that only works because of inheritance.

These do not pass:

  • Engine extends Driver, because an engine drives nothing
  • Stack extends Vector, since a stack should never allow inserts in the middle
  • OrderService extends DatabaseHelper, which reuses code but breaks the sentence

8. When Composition Wins

8.1 The Warning Signs

Reach for a field instead of extends when you notice any of these:

  • You extended a class purely to borrow two or three methods
  • Your subclass overrides parent methods just to throw UnsupportedOperationException
  • The parent lives in a library you do not control and cannot review
  • You need behaviour from two different sources, and Java allows only one superclass
  • Tests need a fake collaborator, and the parent keeps dragging real logic in

That second sign is the loudest of all. Blocking inherited methods proves the IS-A relationship was false from the start.

8.2 What You Give Up

Composition is not free, and honest advice says so.

  • Forwarding methods. Wrapping a ten-method type may mean writing ten one-line methods.
  • No automatic substitution. Your wrapper is not the wrapped type, so callers expecting that type reject it.
  • An extra hop. Readers follow one more level of indirection to find the real work.

An interface usually fixes the middle point. Implement the same interface your field implements, and substitutability comes back without the coupling.

9. Refactoring From Inheritance to Composition

9.1 The Version That Extends

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 dumped

Spot the leak on that last line. Every caller of SalesReport can now invoke debugDump, because inheritance published the whole Formatter API.

9.2 The Version That Delegates

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.0

9.3 Reading the Difference

Three things improved, and only one line of real code moved.

  • A smaller API. SalesReport now exposes print and nothing else.
  • Safety from upstream edits. Adding a method to Formatter no longer changes what SalesReport offers.
  • An honest sentence. A report HAS-A formatter, which is exactly what the field says.

The recipe generalises nicely. Turn the parent into a private field, keep the methods you actually call, and delete the rest of the surface.

10. Common Mistakes and Pitfalls

10.1 Extending Just to Borrow a Method

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.

10.2 Making Fields protected by Reflex

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.

10.3 Forgetting That Subclasses See the Whole API

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?

10.4 Writing Deep Hierarchies

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.

10.5 Treating Composition as Always Correct

“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.

11. Interview Questions

Q: What is the difference between inheritance and composition in Java?

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.

Q: Why do people say to favour composition over inheritance?

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.

Q: What is the fragile base class problem?

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.

Q: Can a subclass access private members of its superclass?

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.

Q: How does protected access work across packages?

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.

Q: How do you prevent a class from being extended in Java?

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().

Q: What does final mean on a field versus on a class?

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.

Q: Why does every Java class extend Object?

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.

Q: What are sealed classes and how do they relate to inheritance?

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.

Q: Does composition have any downsides?

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.

12. Conclusion

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.

Further Reading

Leave a Comment