Blog

Interfaces, Inheritance and Composition in Java

Interfaces say what a type can do, inheritance hands a class everything its parent does, and composition wraps an object instead. Learn default methods, super, abstract classes and the fragile base class trap by running small programs.

Java gives you three ways to connect types. An interface is a contract that any class can sign. Inheritance, with extends, gives a class everything its parent has. Composition keeps another object in a field and calls it. Picking the wrong one is how a subclass ends up broken by code it never saw.

This post covers interfaces, default, static and private interface methods, extends and super, @Override, protected and final, abstract classes, the fragile base class problem, composition with forwarding and decorators, and casting. Every program below was run on Java 25, and its output is pasted from the run. To run one yourself, save it as Main.java and run java Main.java.

Interfaces are contracts

An interface lists methods a type promises to have, without saying how they work. Any class or record can implement it, and code that only knows the interface can use all of them. Here are three shapes that have nothing else in common:

interface Shape {
    double area();

    String name();
}

record Circle(double radius) implements Shape {
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }

    @Override
    public String name() {
        return "circle";
    }
}

record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }

    @Override
    public String name() {
        return "rectangle";
    }
}

class Triangle implements Shape {
    private final double base;
    private final double height;

    Triangle(double base, double height) {
        this.base = base;
        this.height = height;
    }

    @Override
    public double area() {
        return base * height / 2;
    }

    @Override
    public String name() {
        return "triangle";
    }
}

double totalArea(List<Shape> shapes) {
    double total = 0;
    for (Shape s : shapes) {
        total += s.area();
    }
    return total;
}

void main() {
    List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3), new Triangle(4, 5));
    for (Shape s : shapes) {
        IO.println(String.format("%-9s %6.2f", s.name(), s.area()));
    }
    IO.println(String.format("total     %6.2f", totalArea(shapes)));
}

It prints:

circle      3.14
rectangle   6.00
triangle   10.00
total      19.14

totalArea never mentions circles or triangles. It asks each Shape for its area, and each object answers with its own code. That’s polymorphism: one call, s.area(), and the object decides which method runs. Add a Hexagon tomorrow and totalArea works without a change.

The methods in an interface are public whether you write it or not. That’s why every implementation says public double area(). Leave it off and javac refuses with area() in Circle cannot implement area() in Shape.

Default and static methods let an interface grow

A default method is an interface method with a body. Every class that implements the interface gets it for free, and any of them can override it. Once many classes implement an interface, adding an abstract method breaks all of them. A default method doesn’t.

interface Shape {
    double area();

    default String describe() {
        return String.format("a shape with area %.2f", area());
    }

    static Shape square(double side) {
        return new Rectangle(side, side);
    }
}

record Circle(double radius) implements Shape {
    @Override
    public double area() {
        return Math.PI * radius * radius;
    }
}

record Rectangle(double width, double height) implements Shape {
    @Override
    public double area() {
        return width * height;
    }

    @Override
    public String describe() {
        return "a " + width + " by " + height + " rectangle";
    }
}

void main() {
    List<Shape> shapes = List.of(new Circle(1), new Rectangle(2, 3), Shape.square(4));
    for (Shape s : shapes) {
        IO.println(s.describe());
    }
}

It prints:

a shape with area 3.14
a 2.0 by 3.0 rectangle
a 4.0 by 4.0 rectangle

Circle never wrote describe, so it got the default. The default calls area(), and that call reaches the circle’s own area. Rectangle overrode describe with something better. The JDK used this to add stream() to Collection without breaking every collection class.

square is a static interface method. You call it on the interface, Shape.square(4), and it isn’t inherited: Rectangle.square(4) doesn’t compile. It’s a good home for factory methods that belong to the contract, like List.of.

Two defaults with the same name don’t compile

A class can implement two interfaces, and sometimes both have a default method with the same signature. Java won’t pick one for you:

interface Camera {
    default String describe() {
        return "takes photos";
    }
}

interface Phone {
    default String describe() {
        return "makes calls";
    }
}

class SmartPhone implements Camera, Phone {
}

void main() {
    IO.println(new SmartPhone().describe());
}

The build fails with:

Main.java:13: error: types Camera and Phone are incompatible;
class SmartPhone implements Camera, Phone {
^

The next line of javac’s output explains it: class Main.SmartPhone inherits unrelated defaults for describe() from types Camera and Phone. The Main. is there because a compact source file nests your classes inside a hidden Main class, as the part on classes explains.

This is called the diamond problem, and Java makes you settle it by hand. Override the method, and call whichever defaults you want with Interface.super.method():

interface Camera {
    default String describe() {
        return "takes photos";
    }
}

interface Phone {
    default String describe() {
        return "makes calls";
    }
}

class SmartPhone implements Camera, Phone {
    @Override
    public String describe() {
        return Phone.super.describe() + " and " + Camera.super.describe();
    }
}

void main() {
    IO.println(new SmartPhone().describe());
}

It prints:

makes calls and takes photos

Phone.super.describe() means “run the default that Phone provides, on this object”. You can call one, both or neither, but you have to choose.

Private methods share code between defaults

When two default methods need the same helper, the helper can be a private interface method. It isn’t part of the contract, so implementers can’t see or call it:

interface Greeter {
    String name();

    default String hello() {
        return wrap("Hello, " + name());
    }

    default String goodbye() {
        return wrap("Goodbye, " + name());
    }

    private String wrap(String text) {
        return "[" + text + "]";
    }
}

record English(String name) implements Greeter {}

void main() {
    var g = new English("Ana");
    IO.println(g.hello());
    IO.println(g.goodbye());
}

It prints:

[Hello, Ana]
[Goodbye, Ana]

Private interface methods arrived in Java 9. We checked: the same interface compiled with javac --release 8 fails with private interface methods are not supported in -source 8. English needs no body: the record’s accessor name() fulfils the interface method.

Interfaces with one method can be lambdas

An interface with exactly one abstract method is called a functional interface, and Java lets you write an implementation of it as a lambda instead of a class. Runnable, Comparator and Function are all interfaces of this kind, and default methods don’t count against the “one”. The @FunctionalInterface annotation asks the compiler to check that an interface stays that way. The part on lambdas and streams covers how to write and use them.

Inheritance with extends

A class that extends another inherits its fields and methods, and can override the methods to change what they do. The class it extends is its superclass, or parent. A class can extend only one class. Here’s a small family of animals, three levels deep:

class Animal {
    protected final String name;

    Animal(String name) {
        this.name = name;
    }

    String sound() {
        return "...";
    }

    String describe() {
        return name + " says " + sound();
    }
}

class Dog extends Animal {
    Dog(String name) {
        super(name);
    }

    @Override
    String sound() {
        return "Woof";
    }
}

class Puppy extends Dog {
    Puppy(String name) {
        super(name);
    }

    @Override
    String sound() {
        return super.sound().toLowerCase() + "?";
    }

    @Override
    String describe() {
        return super.describe() + " (a puppy called " + name + ")";
    }
}

void main() {
    List<Animal> animals = List.of(new Animal("Rock"), new Dog("Rex"), new Puppy("Bit"));
    for (Animal a : animals) {
        IO.println(a.describe());
    }
}

It prints:

Rock says ...
Rex says Woof
Bit says woof? (a puppy called Bit)

A lot happened in a short program:

  • super(name) calls the parent’s constructor. Animal has no no-argument constructor, so Dog must call this one. Every constructor runs its parent’s constructor before its own fields are ready.
  • super.sound() calls the parent’s version of a method, even though this class overrides it. Puppy took the dog’s "Woof" and changed it.
  • protected on name lets subclasses read it, which is how Puppy.describe used name. It also opens the field to every class in the same package, and all the classes in one file share a package, so a one-file program can’t show protected refusing anyone.
  • Dynamic dispatch is the line that matters most. describe() is written once, in Animal, and it calls sound(). When describe() runs on a Dog, that sound() call goes to Dog.sound(). Java picks the method by the object’s real class at run time, not by the type of the variable or the class the call was written in.

That rule makes inheritance powerful, and it’s also what makes it dangerous.

@Override catches the typo

@Override tells the compiler that a method is meant to replace one in a parent. It’s optional, and leaving it out lets a typo through. Here Dog misspells sound:

class Animal {
    String sound() {
        return "...";
    }
}

class Dog extends Animal {
    String sonud() {
        return "Woof";
    }
}

void main() {
    Animal rex = new Dog();
    IO.println(rex.sound());
}

It prints:

...

That compiled, even under javac -Xlint:all -Werror. sonud is a brand new method that nothing calls, and the dog quietly makes the parent’s sound. Put @Override on it:

class Animal {
    String sound() {
        return "...";
    }
}

class Dog extends Animal {
    @Override
    String sonud() {
        return "Woof";
    }
}

void main() {
    Animal rex = new Dog();
    IO.println(rex.sound());
}

The build fails with:

Main.java:8: error: method does not override or implement a method from a supertype
    @Override
    ^

It also catches a wrong parameter type, which makes an overload instead of an override.

final stops overriding

A final method can’t be overridden, and a final class can’t be extended. Use final on a method when the parent’s rules must hold for every subclass:

class Account {
    private int balance;

    final void deposit(int amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("deposit must be positive");
        }
        balance += amount;
    }
}

class SneakyAccount extends Account {
    @Override
    void deposit(int amount) {
        IO.println("no checks here");
    }
}

void main() {
    new SneakyAccount().deposit(-50);
}

The build fails with:

Main.java:14: error: deposit(int) in Main.SneakyAccount cannot override deposit(int) in Main.Account
    void deposit(int amount) {
         ^

javac adds overridden method is final on the next line. On a whole class, final closes the door completely. String is final, so class LoudString extends String fails with cannot inherit from final String. Records are final too. The part on sealed types covers the middle ground, where a class lists exactly which subclasses it allows.

A constructor that calls an overridable method

A parent constructor runs before the child’s constructor body, and dynamic dispatch still works inside it. Put those two facts together and a parent can call a child’s method before the child has set its fields:

class Widget {
    Widget() {
        IO.println("Widget constructor calls render()");
        render();
    }

    void render() {
        IO.println("plain widget");
    }
}

class Label extends Widget {
    private final String text;

    Label(String text) {
        this.text = text;
    }

    @Override
    void render() {
        IO.println("label: " + text.toUpperCase());
    }
}

void main() {
    new Label("hello");
}

It prints, then stops:

Widget constructor calls render()
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.toUpperCase()" because "this.text" is null

text is final, and it’s still null here. The Label constructor’s first step was an invisible super(), which ran Widget(), which called render(), which reached Label.render(). this.text = text hadn’t run yet. A final field is assigned once, but you can read it before that happens.

Java 25 gives you a fix. Its flexible constructor bodies let a constructor assign its own fields before calling super(), as the part on classes shows. Write this.text = text; and then super(); in Label‘s constructor, and the same program prints label: HELLO. The older rule is simpler: don’t call overridable methods from a constructor.

Abstract classes vs interfaces

An abstract class is a class you can’t create directly. It can have fields, constructors and finished methods, plus abstract methods that each subclass must write. new Account() on the class below fails with Main.Account is abstract; cannot be instantiated.

abstract class Account {
    private int balance;
    private final List<String> history = new ArrayList<>();

    abstract int fee(int amount);

    final void withdraw(int amount) {
        int total = amount + fee(amount);
        balance -= total;
        history.add("-" + total);
    }

    final void deposit(int amount) {
        balance += amount;
        history.add("+" + amount);
    }

    String statement() {
        return getClass().getSimpleName() + " " + history + " balance " + balance;
    }
}

class Checking extends Account {
    @Override
    int fee(int amount) {
        return 1;
    }
}

class Savings extends Account {
    @Override
    int fee(int amount) {
        return amount / 10;
    }
}

void main() {
    List<Account> accounts = List.of(new Checking(), new Savings());
    for (Account a : accounts) {
        a.deposit(100);
        a.withdraw(50);
        IO.println(a.statement());
    }
}

It prints:

Checking [+100, -51] balance 49
Savings [+100, -55] balance 45

Account owns the balance, the history and the rules for changing them. Its final methods fix the steps, and the one abstract method, fee, is the only gap each subclass fills. That shape is called the template method pattern.

An interface couldn’t do this, and the difference comes down to three things:

  • State. An abstract class can have instance fields and a constructor. An interface can’t. It can only have constants, and its default methods must work through other methods.
  • How many. A class extends one class, but implements any number of interfaces. A record or an enum can’t extend a class at all, but can implement interfaces.
  • Coupling. Extending a class ties you to its fields and its implementation. Implementing an interface ties you only to method signatures.

Start with an interface, because anything can sign it. Add an abstract class when implementations share real state and steps, and keep the interface as the type people use. The JDK does that with List and AbstractList.

The fragile base class problem

A subclass depends on how its parent is written inside, not just on what the parent promises. When those details change, or were never what you assumed, the subclass breaks. This is the classic example, from Joshua Bloch’s Effective Java: a HashSet that counts how many elements were ever added.

class InstrumentedHashSet<E> extends HashSet<E> {
    private static final long serialVersionUID = 1L;

    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    int addCount() {
        return addCount;
    }
}

void main() throws Exception {
    var set = new InstrumentedHashSet<String>();
    set.addAll(List.of("Snap", "Crackle", "Pop"));
    IO.println("size:      " + set.size());
    IO.println("addCount:  " + set.addCount());

    var addAll = HashSet.class.getMethod("addAll", Collection.class);
    IO.println("addAll is written in " + addAll.getDeclaringClass().getName());
}

It prints:

size:      3
addCount:  6
addAll is written in java.util.AbstractCollection

Three elements went in, and the count says six. We ran it on Java 25 to check whether the JDK still behaves this way, and it does. The last line shows why: HashSet doesn’t write its own addAll. It inherits one from AbstractCollection, and that one loops over the collection and calls add for each element. getMethod can throw a checked exception, which is why main says throws Exception.

Here’s the call chain:

set.addAll(List.of(3 elements)) InstrumentedHashSet.addAll: addCount += 3 super.addAll(c) AbstractCollection.addAll: add(e) for each e add(e) finds the override InstrumentedHashSet.add: addCount++ (x3) super.add(e) HashSet.add: stores the element addCount 3 3 6, counted twice 6, but size is 3

One call to addAll counts the three elements, then hands off to the inherited AbstractCollection.addAll. That method calls add for each element, and dynamic dispatch sends each call back to the subclass’s add, which counts them again.

Both overrides are correct on their own. The bug comes from a fact that isn’t in HashSet‘s contract: which of its own methods it calls internally. That’s called self-use, and you can’t see it from the outside.

The obvious fix is to delete the addAll override, and it works today. But now your count is correct only because AbstractCollection.addAll happens to call add. If a future JDK gave HashSet a faster addAll that stores elements directly, your count would silently drop to zero for bulk adds. Either way, your class depends on code you don’t own and can’t see.

Explain it like I’m ten

Inheritance is getting your parent’s whole house. You get the rooms, the furniture and the garden, and you move in on day one without building anything. You also get the leaky roof you didn’t know about, and the light switch in the hall that secretly also turns on the garden hose. You can repaint any room, but the wiring behind the walls is still theirs.

Composition is hiring a plumber when you need one. You live in your own house. When a pipe needs fixing, you call the plumber and tell them which pipe. They can change how they work, buy new tools or retire, and you just call a different plumber. Nothing they do can flood a room you didn’t ask them into.

The precise version

A subclass inherits a parent’s implementation, not only its interface. Every method it overrides can be called by the parent’s own code, because Java dispatches on the object’s run-time class. So the subclass’s correctness depends on the parent’s self-use: which methods call which other methods, in what order. That detail is usually undocumented, and the parent’s author is free to change it in the next version.

Composition doesn’t have this problem. A wrapper holds the other object in a private field and calls its public methods. The wrapped object’s internal calls go to its own methods, never back into the wrapper, because the wrapper isn’t a subclass. The wrapper depends only on the contract.

Where the analogy breaks: a house can’t be built for the next owner, but a class can. A parent designed for inheritance documents its self-use, as AbstractList does, and marks the rest final. Extending a class like that is safe.

Composition: forward instead of extend

With composition, your class has a set instead of being one. It keeps a Set in a field and forwards each call to it. Here’s the counter again, written that way:

class InstrumentedSet<E> {
    private final Set<E> inner;
    private int addCount = 0;

    InstrumentedSet(Set<E> inner) {
        this.inner = inner;
    }

    boolean add(E e) {
        addCount++;
        return inner.add(e);
    }

    boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return inner.addAll(c);
    }

    int size() {
        return inner.size();
    }

    int addCount() {
        return addCount;
    }

    @Override
    public String toString() {
        return inner.toString();
    }
}

void main() {
    var fromHash = new InstrumentedSet<String>(new HashSet<>());
    fromHash.addAll(List.of("Snap", "Crackle", "Pop"));
    IO.println("HashSet: size " + fromHash.size() + ", addCount " + fromHash.addCount());

    var fromTree = new InstrumentedSet<String>(new TreeSet<>());
    fromTree.addAll(List.of("Snap", "Crackle", "Pop"));
    fromTree.add("Snap");
    IO.println("TreeSet: size " + fromTree.size() + ", addCount " + fromTree.addCount());
    IO.println(fromTree);
}

It prints:

HashSet: size 3, addCount 3
TreeSet: size 3, addCount 4
[Crackle, Pop, Snap]

The count is right. inner.addAll(c) still calls add internally, but that’s the HashSet‘s own add, and our wrapper never hears about it. Adding "Snap" a second time counts as an add attempt, which is what the class promises, while the size stays 3.

There’s a bonus. The wrapper takes any Set, so the same class counts additions to a TreeSet, which keeps its elements sorted. The subclass version could only ever be a HashSet.

The cost is forwarding code. To pass this wrapper where a Set is expected, it would implement Set<E> and forward every method. Effective Java writes those once, in a reusable ForwardingSet.

“Is-a” or “has-a”

Inheritance says a Dog is an Animal: anywhere you need an animal, a dog must fit. Composition says a class has a thing it uses. Before you write extends, ask whether every method of the parent makes sense on the child. The JDK itself got this wrong once. Stack extends Vector, so a stack is a list, and it inherits list methods that break the idea of a stack:

void main() {
    var stack = new Stack<String>();
    stack.push("first");
    stack.push("second");
    stack.push("third");

    stack.add(0, "sneaked in at the bottom");
    stack.remove(2);

    IO.println(stack);
    IO.println("pop: " + stack.pop());
}

It prints:

[sneaked in at the bottom, first, third]
pop: third

A stack should only let you push and pop at the top. This one let us insert at the bottom and delete from the middle, because Stack can’t take Vector‘s methods away. A stack has a list inside it. It isn’t one. That’s why Stack‘s own documentation tells you to use a Deque, such as ArrayDeque, instead.

A decorator stacks wrappers

A decorator is a wrapper that implements the same interface as the object it wraps. Because the wrapper is also that type, you can wrap a wrapper, and build behaviour in layers. Records make each layer one short type:

interface Formatter {
    String format(String text);
}

record Plain() implements Formatter {
    @Override
    public String format(String text) {
        return text;
    }
}

record Shout(Formatter inner) implements Formatter {
    @Override
    public String format(String text) {
        return inner.format(text).toUpperCase();
    }
}

record Exclaim(Formatter inner) implements Formatter {
    @Override
    public String format(String text) {
        return inner.format(text) + "!";
    }
}

record Timestamp(Formatter inner, String time) implements Formatter {
    @Override
    public String format(String text) {
        return "[" + time + "] " + inner.format(text);
    }
}

void main() {
    Formatter f = new Timestamp(new Exclaim(new Shout(new Plain())), "09:30");
    IO.println(f.format("server started"));

    Formatter g = new Shout(new Timestamp(new Plain(), "9am"));
    IO.println(g.format("server started"));
}

It prints:

[09:30] SERVER STARTED!
[9AM] SERVER STARTED

Each layer calls the one inside it, then adds its own change. In g, Shout is on the outside, so it uppercased the timestamp too, and 9am became 9AM. Order matters, and you pick it when you build the object.

With inheritance you’d need a subclass for every combination. Here four small types cover them all. The JDK’s I/O classes work this way: a BufferedReader wraps an InputStreamReader, which wraps an InputStream.

instanceof and casting

instanceof checks whether an object is of a given type, and a cast tells the compiler to treat it as that type. The compiler trusts a cast, so the check happens at run time. When the object isn’t that type, the cast throws:

void main() {
    List<Object> values = List.of("8080", 8080);
    for (Object v : values) {
        if (v instanceof String) {
            String s = (String) v;
            IO.println("a string of length " + s.length());
        } else {
            IO.println("not a string: " + v);
        }
    }
    String port = (String) values.get(1);
    IO.println("port is " + port);
}

It prints, then stops:

a string of length 4
not a string: 8080
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')

The loop was safe because its cast sat behind an instanceof check. The last cast had no check, and the value is an Integer. Real code hits this when values come out of a Map<String, Object>.

When you cast your own classes in a compact source file, the message names the class loader and ends with an @ and a hex number that can change between runs. That’s why this example uses JDK types.

A long chain of instanceof checks usually means the method belongs on the interface, as area() did. When a type check is the right tool, the part on sealed types shows v instanceof String s, which checks and casts in one step.

What to remember

  • An interface is a contract. Any class or record can implement several, and code written against the interface works with all of them.
  • Default methods let an interface add behaviour without breaking implementers. Two conflicting defaults don’t compile; override the method and pick with Camera.super.describe().
  • extends inherits one parent’s fields and methods. Java dispatches on the object’s real class, so a parent’s code can call a child’s override, even from a constructor.
  • Put @Override on every override, so a typo fails the build. Use final to stop overriding and super to call the parent.
  • Use an abstract class when implementations share real state and steps. Otherwise, prefer an interface.
  • Extending a class you don’t control ties you to its hidden self-use. InstrumentedHashSet counts 6 for 3 elements on Java 25, because the inherited addAll calls add.
  • Composition wraps an object and forwards to it, so the wrapped object’s internals can’t call back into your code. Decorators stack those wrappers.

Extend a class only when the child really is the parent and the parent was built to be extended. Otherwise, wrap it.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.